You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d383c43bbf | |||
| 44bfcc49b4 | |||
| b0ffb64cb2 | |||
| bfc6ff21a5 | |||
| ea88934e9e | |||
| bb4b9fb7f4 | |||
| 84d7968b76 | |||
| f5bf4baa79 | |||
| 4a02d22061 | |||
| 14854efaeb | |||
| 564c8d647e | |||
| 0e7138a721 | |||
| 76eb1ecd05 | |||
| 4621ed62b7 | |||
| 2b8ce3cd91 | |||
| 57c36da795 | |||
| 17629c210c | |||
| 50444003bb | |||
| f55b182d8e | |||
| 1416cd7464 | |||
| 392d24b9c5 | |||
| 9dcd58d027 | |||
| 3b85d5fa34 | |||
| 43c2c2b0f1 | |||
| a815c1b99d | |||
| caf6ad35fa | |||
| 258a95a269 | |||
| 4944d41410 | |||
| 8fb117ae73 | |||
| 7f4f7d6da9 | |||
| 0cf46471f2 | |||
| 3a505cd559 | |||
| f2e0f6e2d5 | |||
| d73573e7c5 | |||
| efb88e5957 | |||
| e7e75a285b | |||
| 28c3e93945 | |||
| 5bf1d42cfb | |||
| b162b3f4d8 | |||
| 86498b6b4c | |||
| 964f388594 | |||
| f9644d83b7 | |||
| 0b142d184c | |||
| 867c8ff857 | |||
| 5094d6ce4f |
@@ -0,0 +1,22 @@
|
||||
-- 020_furnidata_edit_log.sql
|
||||
-- Audit trail for furnidata name/description edits made through the furni editor,
|
||||
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
|
||||
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
|
||||
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`classname` varchar(255) NOT NULL,
|
||||
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
|
||||
`old_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`old_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`timestamp` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_classname` (`classname`),
|
||||
INDEX `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||
('items.furnidata.edit.backup.keep','10'),
|
||||
('items.furnidata.edit.ratelimit.ms','2000');
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 020_furnidata_edit_log.sql
|
||||
-- Audit trail for furnidata name/description edits made through the furni editor,
|
||||
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
|
||||
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
|
||||
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`classname` varchar(255) NOT NULL,
|
||||
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
|
||||
`old_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`old_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`timestamp` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_classname` (`classname`),
|
||||
INDEX `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||
('items.furnidata.edit.backup.keep','10'),
|
||||
('items.furnidata.edit.ratelimit.ms','2000');
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 021_furnidata_config.sql
|
||||
-- Seeds the furnidata feature config keys read at runtime by
|
||||
-- FurnitureTextProvider / FurnidataReader / FurnidataWatcher and
|
||||
-- FurniEditorImportTextEvent. Without these rows a fresh install logs
|
||||
-- "Config key not found" for each (ConfigurationManager logs ERROR even
|
||||
-- when a default is supplied) and the values are not editable from the DB.
|
||||
--
|
||||
-- Notes:
|
||||
-- * *.enabled keys are read via Boolean.parseBoolean → use true/false (NOT 1/0).
|
||||
-- * items.furnidata.path is intentionally empty: when blank the source is
|
||||
-- derived from furni.editor.asset.base.path (seeded by 004_furni_editor.sql)
|
||||
-- → <base>/furnidata (split-tier) or <base>/FurnitureData.json (single file).
|
||||
-- * Editor write-path keys (items.furnidata.edit.*) are seeded by 020.
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||
-- Server-authoritative furni names (source of truth = furnidata JSON)
|
||||
('items.furnidata.names.enabled','true'),
|
||||
('items.furnidata.path',''),
|
||||
('items.furnidata.max.bytes','67108864'),
|
||||
-- Live-reload watcher
|
||||
('items.furnidata.watch.enabled','true'),
|
||||
('items.furnidata.watch.debounce.ms','750'),
|
||||
('items.furnidata.watch.min.interval.ms','5000'),
|
||||
('items.furnidata.delta.cap','500'),
|
||||
-- Furni editor: import official names/descriptions from Habbo
|
||||
('furni.editor.import.url','https://www.habbo.it/gamedata/furnidata_json/1'),
|
||||
('furni.editor.import.cache.ms','600000');
|
||||
@@ -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;
|
||||
+16
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.eu.habbo</groupId>
|
||||
<artifactId>Habbo</artifactId>
|
||||
<version>4.2.36</version>
|
||||
<version>4.2.39</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -62,6 +62,12 @@
|
||||
<show>public</show>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
@@ -172,12 +178,20 @@
|
||||
<version>0.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Jakarta Mail � used by the built-in forgot-password endpoint
|
||||
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
|
||||
when smtp.* keys are configured in emulator_settings -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.angus</groupId>
|
||||
<artifactId>jakarta.mail</artifactId>
|
||||
<version>2.0.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JUnit Jupiter -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.eu.habbo.habbohotel.crafting.CraftingManager;
|
||||
import com.eu.habbo.habbohotel.guides.GuideManager;
|
||||
import com.eu.habbo.habbohotel.guilds.GuildManager;
|
||||
import com.eu.habbo.habbohotel.hotelview.HotelViewManager;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||
import com.eu.habbo.habbohotel.items.ItemManager;
|
||||
import com.eu.habbo.habbohotel.modtool.ModToolManager;
|
||||
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
|
||||
@@ -47,6 +48,7 @@ public class GameEnvironment {
|
||||
private NavigatorManager navigatorManager;
|
||||
private GuildManager guildManager;
|
||||
private ItemManager itemManager;
|
||||
private FurnitureTextProvider furnitureTextProvider;
|
||||
private CatalogManager catalogManager;
|
||||
private HotelViewManager hotelViewManager;
|
||||
private RoomManager roomManager;
|
||||
@@ -79,6 +81,8 @@ public class GameEnvironment {
|
||||
this.hotelViewManager = new HotelViewManager();
|
||||
this.itemManager = new ItemManager();
|
||||
this.itemManager.load();
|
||||
this.furnitureTextProvider = new FurnitureTextProvider();
|
||||
this.furnitureTextProvider.init();
|
||||
this.botManager = new BotManager();
|
||||
this.petManager = new PetManager();
|
||||
this.guildManager = new GuildManager();
|
||||
@@ -161,6 +165,10 @@ public class GameEnvironment {
|
||||
return this.itemManager;
|
||||
}
|
||||
|
||||
public FurnitureTextProvider getFurnitureTextProvider() {
|
||||
return this.furnitureTextProvider;
|
||||
}
|
||||
|
||||
public CatalogManager getCatalogManager() {
|
||||
return this.catalogManager;
|
||||
}
|
||||
|
||||
@@ -1054,13 +1054,13 @@ public class CatalogManager {
|
||||
if (Emulator.getConfig().getBoolean("hotel.catalog.ltd.limit.enabled")) {
|
||||
int ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.total");
|
||||
if (habbo.getHabboStats().totalLtds() >= ltdLimit) {
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + ""));
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + ""));
|
||||
return;
|
||||
}
|
||||
|
||||
ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.item");
|
||||
if (habbo.getHabboStats().totalLtds(item.id) >= ltdLimit) {
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + ""));
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + ""));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
/**
|
||||
* One parsed furnidata entry. {@code classname} is the raw furnidata classname
|
||||
* (may carry a {@code *N} colour-variant suffix); the provider keys on the base.
|
||||
*/
|
||||
public record FurnidataEntry(int id, String classname, FurnitureType type, String name, String description) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* One process-wide lock serializing every furnidata reindex and every editor-driven
|
||||
* furnidata write, so an editor write never races the file watcher's reindex and the
|
||||
* volatile index is never observed mid-swap by two writers.
|
||||
*/
|
||||
public final class FurnidataLock {
|
||||
public static final ReentrantLock LOCK = new ReentrantLock();
|
||||
private FurnidataLock() {}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Neutral furnidata reader. Supports a single JSON/JSON5 file or a split-tier
|
||||
* directory ({@code core/custom/seasonal} with {@code manifest.json(5)}).
|
||||
* Never throws: any IO/parse error yields an empty list (the caller decides the
|
||||
* fallback). All resolved paths are guarded against escaping the base dir.
|
||||
*/
|
||||
public class FurnidataReader {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataReader.class);
|
||||
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 final Path source;
|
||||
private final long maxBytes;
|
||||
|
||||
public FurnidataReader(Path source, long maxBytes) {
|
||||
this.source = source;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
public List<FurnidataEntry> read() {
|
||||
List<FurnidataEntry> out = new ArrayList<>();
|
||||
try {
|
||||
if (this.source == null || !Files.exists(this.source)) return out;
|
||||
|
||||
if (Files.isDirectory(this.source)) {
|
||||
readSplitDir(this.source, out);
|
||||
} else {
|
||||
String content = readJson5Capped(this.source);
|
||||
if (content != null) {
|
||||
parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataReader failed to read {} — returning empty", this.source, e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void readSplitDir(Path base, List<FurnidataEntry> out) {
|
||||
List<String> tiers = readManifestList(base, "tiers", DEFAULT_TIERS);
|
||||
Path baseNorm = base.toAbsolutePath().normalize();
|
||||
|
||||
for (String tier : tiers) {
|
||||
Path tierDir = base.resolve(tier);
|
||||
if (!isInside(baseNorm, tierDir) || !Files.isDirectory(tierDir)) continue;
|
||||
|
||||
for (String fileName : readManifestList(tierDir, "files", List.of())) {
|
||||
Path file = tierDir.resolve(fileName);
|
||||
if (!isInside(baseNorm, file)) {
|
||||
LOGGER.warn("FurnidataReader: ignoring out-of-base file {}", file);
|
||||
continue;
|
||||
}
|
||||
if (!Files.exists(file)) continue;
|
||||
try {
|
||||
String content = readJson5Capped(file);
|
||||
if (content != null) parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataReader: failed to parse {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readManifestList(Path dir, String key, List<String> fallback) {
|
||||
for (String name : MANIFEST_NAMES) {
|
||||
Path m = dir.resolve(name);
|
||||
if (!Files.exists(m)) continue;
|
||||
try {
|
||||
String raw = readJson5Capped(m);
|
||||
if (raw == null) continue;
|
||||
JsonObject obj = JsonParser.parseString(raw).getAsJsonObject();
|
||||
if (obj.has(key) && obj.get(key).isJsonArray()) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString());
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataReader: bad manifest {}", m, e);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private void parseRoot(JsonObject root, List<FurnidataEntry> out) {
|
||||
for (String section : SECTIONS) {
|
||||
if (!root.has(section)) continue;
|
||||
JsonObject sectionObj = root.getAsJsonObject(section);
|
||||
if (!sectionObj.has("furnitype")) continue;
|
||||
FurnitureType type = section.equals("roomitemtypes") ? FurnitureType.FLOOR : FurnitureType.WALL;
|
||||
JsonArray types = sectionObj.getAsJsonArray("furnitype");
|
||||
for (JsonElement el : types) {
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
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").isJsonNull()) ? o.get("name").getAsString() : "",
|
||||
(o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : ""
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the JSON5-stripped content, or null if the file exceeds the byte cap. */
|
||||
private String readJson5Capped(Path path) throws Exception {
|
||||
long size = Files.size(path);
|
||||
if (size > this.maxBytes) {
|
||||
LOGGER.warn("FurnidataReader: {} is {} bytes, over cap {} — refusing", path, size, this.maxBytes);
|
||||
return null;
|
||||
}
|
||||
return stripJson5(Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static boolean isInside(Path baseNorm, Path candidate) {
|
||||
return candidate.toAbsolutePath().normalize().startsWith(baseNorm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip // and block comments and trailing commas so Gson can parse JSON5.
|
||||
* Known limitation: the trailing-comma pass is a regex over the whole output,
|
||||
* so a string value literally containing ",[whitespace]}" or ",[whitespace]]"
|
||||
* would be altered. Real Habbo furnidata names/descriptions do not contain
|
||||
* that pattern; values are additionally sanitized downstream before use.
|
||||
*/
|
||||
static String stripJson5(String content) {
|
||||
if (content == null || content.isEmpty()) return content;
|
||||
StringBuilder out = new StringBuilder(content.length());
|
||||
int i = 0, len = content.length();
|
||||
boolean inString = false, escape = false;
|
||||
char stringChar = 0;
|
||||
while (i < len) {
|
||||
char c = content.charAt(i);
|
||||
if (inString) {
|
||||
out.append(c);
|
||||
if (escape) escape = false;
|
||||
else if (c == '\\') escape = true;
|
||||
else if (c == stringChar) inString = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"' || c == '\'') { inString = true; stringChar = c; out.append(c); i++; continue; }
|
||||
if (c == '/' && i + 1 < len) {
|
||||
char next = content.charAt(i + 1);
|
||||
if (next == '/') { int eol = content.indexOf('\n', i + 2); if (eol < 0) break; i = eol; continue; }
|
||||
if (next == '*') { int end = content.indexOf("*/", i + 2); if (end < 0) break; i = end + 2; continue; }
|
||||
}
|
||||
out.append(c);
|
||||
i++;
|
||||
}
|
||||
return out.toString().replaceAll(",(\\s*[}\\]])", "$1");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.ClosedWatchServiceException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardWatchEventKinds;
|
||||
import java.nio.file.WatchKey;
|
||||
import java.nio.file.WatchService;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Watches the furnidata source on a single daemon thread. On change (debounced),
|
||||
* re-indexes via the provider and broadcasts only the delta — or a compact
|
||||
* reload-hint when the delta exceeds the cap. A minimum interval throttles bursts.
|
||||
* For the split-tier directory layout, the base dir AND its immediate
|
||||
* subdirectories are registered. Never throws out of the loop.
|
||||
*/
|
||||
public class FurnidataWatcher {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataWatcher.class);
|
||||
|
||||
private final FurnitureTextProvider provider;
|
||||
private final Path watchDir;
|
||||
private final boolean sourceIsDir;
|
||||
private final long maxBytes;
|
||||
private final long debounceMs;
|
||||
private final long minIntervalMs;
|
||||
private final int deltaCap;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private volatile WatchService ws;
|
||||
private long lastBroadcast = 0L;
|
||||
|
||||
public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) {
|
||||
this.provider = provider;
|
||||
this.sourceIsDir = Files.isDirectory(source);
|
||||
this.watchDir = this.sourceIsDir ? source : source.getParent();
|
||||
this.maxBytes = maxBytes;
|
||||
this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750"));
|
||||
this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000"));
|
||||
this.deltaCap = Integer.parseInt(Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (this.running || this.watchDir == null) return;
|
||||
this.running = true;
|
||||
Thread t = new Thread(this::run, "FurnidataWatcher");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
this.running = false;
|
||||
WatchService local = this.ws;
|
||||
if (local != null) {
|
||||
try { local.close(); } catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try {
|
||||
this.ws = FileSystems.getDefault().newWatchService();
|
||||
} catch (IOException e) {
|
||||
LOGGER.warn("FurnidataWatcher: could not create WatchService", e);
|
||||
return;
|
||||
}
|
||||
try (WatchService service = this.ws) {
|
||||
registerDirs(service);
|
||||
while (this.running) {
|
||||
WatchKey key = service.take();
|
||||
key.pollEvents();
|
||||
Thread.sleep(this.debounceMs);
|
||||
key.pollEvents();
|
||||
if (!key.reset()) {
|
||||
LOGGER.warn("FurnidataWatcher: watch key invalidated (directory removed?) — stopping");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
onChange();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataWatcher: onChange failed", e);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ClosedWatchServiceException ignored) {
|
||||
// stop() closed the service — normal shutdown
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataWatcher stopped", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the base dir, plus one level of subdirectories for the split-tier layout. */
|
||||
private void registerDirs(WatchService service) throws IOException {
|
||||
this.watchDir.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||
if (this.sourceIsDir) {
|
||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(this.watchDir)) {
|
||||
for (Path child : ds) {
|
||||
if (Files.isDirectory(child)) {
|
||||
child.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onChange() {
|
||||
FurnidataLock.LOCK.lock();
|
||||
try {
|
||||
Path source = this.provider.getSource();
|
||||
if (source == null) return;
|
||||
|
||||
List<FurnidataEntry> delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
|
||||
if (delta.isEmpty()) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - this.lastBroadcast < this.minIntervalMs) {
|
||||
LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size());
|
||||
return;
|
||||
}
|
||||
this.lastBroadcast = now;
|
||||
|
||||
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
|
||||
broadcast(composer);
|
||||
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
|
||||
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
|
||||
} finally {
|
||||
FurnidataLock.LOCK.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcast(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Comment-preserving, atomic, backed-up writer for furnidata name/description, keyed by
|
||||
* classname. Supports single-file and split-tier (writes the tier that currently resolves
|
||||
* the classname). Edit-only: refuses classnames absent from the furnidata.
|
||||
*/
|
||||
public class FurnidataWriter {
|
||||
|
||||
/** Default tier names in override order (later = higher priority, wins on conflict). */
|
||||
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||
|
||||
/** Manifest filenames tried in order (json5 first, plain json second). */
|
||||
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||
|
||||
private final Path source; // file (single) or base dir (split-tier)
|
||||
private final boolean directory; // true => split-tier
|
||||
private final long maxBytes;
|
||||
private final int backupKeep;
|
||||
|
||||
public FurnidataWriter(Path source, boolean directory, long maxBytes, int backupKeep) {
|
||||
this.source = source;
|
||||
this.directory = directory;
|
||||
this.maxBytes = maxBytes;
|
||||
this.backupKeep = Math.max(1, backupKeep);
|
||||
}
|
||||
|
||||
/** @return true if an entry for classname was found and written. */
|
||||
public boolean write(String classname, String name, String description) throws IOException {
|
||||
String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (cn.isEmpty()) return false;
|
||||
String safeName = FurnitureTextProvider.sanitize(name);
|
||||
String safeDesc = FurnitureTextProvider.sanitize(description);
|
||||
|
||||
Path target = locateFile(cn);
|
||||
if (target == null) return false;
|
||||
|
||||
String raw = Files.readString(target, StandardCharsets.UTF_8);
|
||||
String edited = replaceEntryFields(raw, cn, safeName, safeDesc);
|
||||
if (edited == null || edited.equals(raw)) {
|
||||
// classname not present in this file, or no change
|
||||
return edited != null && !edited.equals(raw);
|
||||
}
|
||||
backup(target);
|
||||
atomicWrite(target, edited);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** For single-file just returns the file; for split-tier, the tier file that contains cn. */
|
||||
private Path locateFile(String cn) throws IOException {
|
||||
if (!directory) {
|
||||
// confirm existence via the reader (size-guarded, parses the same way)
|
||||
return containsClassname(source, cn) ? source : null;
|
||||
}
|
||||
// split-tier: iterate tiers in OVERRIDE order (later tiers win); pick the last containing cn
|
||||
Path winner = null;
|
||||
for (Path tierFile : splitTierFilesInOrder()) {
|
||||
if (containsClassname(tierFile, cn)) winner = tierFile;
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
private boolean containsClassname(Path file, String cn) {
|
||||
for (FurnidataEntry e : new FurnidataReader(file, maxBytes).read()) {
|
||||
if (e.classname() != null && e.classname().trim().toLowerCase(java.util.Locale.ROOT).equals(cn)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the "name" and "description" string values inside the JSON object that holds
|
||||
* "classname": "<cn>". Preserves everything else (comments, ordering, formatting).
|
||||
* Handles double- and single-quoted JSON5 keys/values. Returns null if cn not found.
|
||||
*/
|
||||
static String replaceEntryFields(String raw, String cn, String name, String description) {
|
||||
// find the classname value occurrence (case-insensitive on the value)
|
||||
Pattern classProp = Pattern.compile(
|
||||
"([\"'])classname\\1\\s*:\\s*([\"'])((?:\\\\.|(?!\\2).)*)\\2", Pattern.CASE_INSENSITIVE);
|
||||
Matcher m = classProp.matcher(raw);
|
||||
int objStart = -1, objEnd = -1;
|
||||
while (m.find()) {
|
||||
String val = m.group(3).trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (!val.equals(cn)) continue;
|
||||
// expand to the enclosing { ... }
|
||||
objStart = lastUnbalancedBrace(raw, m.start());
|
||||
objEnd = matchingClose(raw, objStart);
|
||||
break;
|
||||
}
|
||||
if (objStart < 0 || objEnd < 0) return null;
|
||||
String obj = raw.substring(objStart, objEnd + 1);
|
||||
String newObj = replaceField(obj, "name", name);
|
||||
newObj = replaceField(newObj, "description", description);
|
||||
return raw.substring(0, objStart) + newObj + raw.substring(objEnd + 1);
|
||||
}
|
||||
|
||||
private static String replaceField(String obj, String field, String value) {
|
||||
Pattern p = Pattern.compile(
|
||||
"(([\"'])" + Pattern.quote(field) + "\\2\\s*:\\s*)([\"'])((?:\\\\.|(?!\\3).)*)\\3");
|
||||
Matcher m = p.matcher(obj);
|
||||
if (!m.find()) return obj; // field absent → leave object as-is
|
||||
String replacement = m.group(1) + '"' + jsonEscape(value) + '"';
|
||||
return obj.substring(0, m.start()) + replacement + obj.substring(m.end());
|
||||
}
|
||||
|
||||
private static int lastUnbalancedBrace(String s, int from) {
|
||||
int depth = 0;
|
||||
for (int i = from; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '}') depth++;
|
||||
else if (c == '{') { if (depth == 0) return i; depth--; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int matchingClose(String s, int open) {
|
||||
int depth = 0; boolean inStr = false; char q = 0;
|
||||
for (int i = open; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (inStr) { if (c == '\\') { i++; } else if (c == q) inStr = false; continue; }
|
||||
if (c == '"' || c == '\'') { inStr = true; q = c; }
|
||||
else if (c == '{') depth++;
|
||||
else if (c == '}') { depth--; if (depth == 0) return i; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static String jsonEscape(String v) {
|
||||
StringBuilder b = new StringBuilder(v.length() + 8);
|
||||
for (int i = 0; i < v.length(); i++) {
|
||||
char c = v.charAt(i);
|
||||
if (c == '"' || c == '\\') b.append('\\').append(c);
|
||||
else b.append(c);
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every data file reachable from the split-tier base directory, in
|
||||
* override order (core → custom → seasonal, or the order declared in the top-level
|
||||
* {@code manifest.json(5)}). Within each tier the per-tier manifest's {@code files}
|
||||
* array determines the file order.
|
||||
*
|
||||
* <p>All resolved paths are checked against the normalised base directory via
|
||||
* {@link #safeResolve}: any entry that would escape the base is silently skipped.
|
||||
*
|
||||
* @return ordered list of existing, in-bounds data files (earliest tier first).
|
||||
*/
|
||||
private List<Path> splitTierFilesInOrder() throws IOException {
|
||||
Path base = source.toAbsolutePath().normalize();
|
||||
List<String> tiers = manifestList(base, "tiers", DEFAULT_TIERS);
|
||||
List<Path> result = new ArrayList<>();
|
||||
|
||||
for (String tier : tiers) {
|
||||
Path tierDir = safeResolve(base, tier);
|
||||
if (tierDir == null || !Files.isDirectory(tierDir)) continue;
|
||||
|
||||
for (String fileName : manifestList(tierDir, "files", List.of())) {
|
||||
Path file = safeResolve(base, tierDir.resolve(fileName).toString());
|
||||
if (file == null || !Files.isRegularFile(file)) continue;
|
||||
result.add(file);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve {@code entry} relative to {@code base} and verify the result stays
|
||||
* inside {@code base} (path-traversal guard).
|
||||
*
|
||||
* @param base the normalised absolute base directory.
|
||||
* @param entry a path string (may be relative or absolute, may contain {@code ..}).
|
||||
* @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise.
|
||||
*/
|
||||
private static Path safeResolve(Path base, String entry) {
|
||||
try {
|
||||
Path resolved = base.resolve(entry).toAbsolutePath().normalize();
|
||||
return resolved.startsWith(base) ? resolved : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the {@code key} string-array from the first manifest file found in {@code dir}
|
||||
* ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback}
|
||||
* if no manifest exists or the key is absent/empty.
|
||||
*/
|
||||
private List<String> manifestList(Path dir, String key, List<String> fallback) {
|
||||
for (String name : MANIFEST_NAMES) {
|
||||
Path m = dir.resolve(name);
|
||||
if (!Files.exists(m)) continue;
|
||||
try {
|
||||
String stripped = FurnidataReader.stripJson5(
|
||||
Files.readString(m, StandardCharsets.UTF_8));
|
||||
com.google.gson.JsonObject obj =
|
||||
com.google.gson.JsonParser.parseString(stripped).getAsJsonObject();
|
||||
if (obj.has(key) && obj.get(key).isJsonArray()) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (com.google.gson.JsonElement el : obj.getAsJsonArray(key))
|
||||
list.add(el.getAsString());
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// bad manifest → fall through to next candidate / fallback
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private void backup(Path target) throws IOException {
|
||||
Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime());
|
||||
Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
pruneBackups(target);
|
||||
}
|
||||
|
||||
private void pruneBackups(Path target) throws IOException {
|
||||
String prefix = target.getFileName() + ".bak.";
|
||||
try (var stream = Files.list(target.getParent())) {
|
||||
List<Path> baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||
.sorted(Comparator.comparingLong(p -> backupStamp(p))).toList();
|
||||
for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static long backupStamp(Path p) {
|
||||
String s = p.getFileName().toString();
|
||||
try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; }
|
||||
}
|
||||
|
||||
private void atomicWrite(Path target, String content) throws IOException {
|
||||
Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime());
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the most recent backup of the (single-file) target. @return true if restored. */
|
||||
public boolean revertLastBackup() throws IOException {
|
||||
if (directory) return revertSplitTier();
|
||||
return revertFile(source);
|
||||
}
|
||||
|
||||
private boolean revertFile(Path target) throws IOException {
|
||||
String prefix = target.getFileName() + ".bak.";
|
||||
try (var stream = Files.list(target.getParent())) {
|
||||
Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||
.max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null);
|
||||
if (latest == null) return false;
|
||||
atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean revertSplitTier() throws IOException {
|
||||
boolean any = false;
|
||||
for (Path f : splitTierFilesInOrder()) any |= revertFile(f);
|
||||
return any;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* In-memory index of furnidata display names, keyed by the lowercased base
|
||||
* classname (the {@code *N} colour-variant suffix is stripped). Read lazily by
|
||||
* {@link Item#getDisplayName()}. Names are sanitized at index time.
|
||||
*
|
||||
* Thread-safety: the index is held behind a {@code volatile} reference; readers
|
||||
* never block; {@link #reindex(List)} builds a fresh map and swaps it atomically.
|
||||
*/
|
||||
public class FurnitureTextProvider {
|
||||
|
||||
private static final int MAX_LEN = 256;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnitureTextProvider.class);
|
||||
private static final long DEFAULT_MAX_BYTES = 64L * 1024 * 1024;
|
||||
|
||||
private final boolean enabled;
|
||||
private volatile Map<String, FurniText> index = Map.of();
|
||||
private volatile Path source;
|
||||
private FurnidataWatcher watcher;
|
||||
|
||||
public FurnitureTextProvider(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/** Production constructor: reads the enable toggle from config. */
|
||||
public FurnitureTextProvider() {
|
||||
this(Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.names.enabled", "true")));
|
||||
}
|
||||
|
||||
/** Resolve the furnidata source from config and build the initial index. Never throws. */
|
||||
public void init() {
|
||||
try {
|
||||
this.source = resolveSource();
|
||||
if (this.source == null) {
|
||||
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved - names fall back to public_name");
|
||||
return;
|
||||
}
|
||||
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||
LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source);
|
||||
|
||||
if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) {
|
||||
if (this.watcher != null) this.watcher.stop();
|
||||
this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES);
|
||||
this.watcher.start();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getSource() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
/** Returns {@code true} when the resolved source is a directory (split-tier layout). */
|
||||
public boolean isSourceDirectory() {
|
||||
return this.source != null && Files.isDirectory(this.source);
|
||||
}
|
||||
|
||||
/** Returns the byte cap used when reading furnidata files. */
|
||||
public long getMaxBytes() {
|
||||
return Long.parseLong(com.eu.habbo.Emulator.getConfig().getValue("items.furnidata.max.bytes", String.valueOf(DEFAULT_MAX_BYTES)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the furnidata from the current source and reindexes atomically.
|
||||
* Returns the delta list (new/changed entries) from {@link #reindex(List)}.
|
||||
* Never throws — returns an empty list when the source is unavailable.
|
||||
*/
|
||||
public java.util.List<FurnidataEntry> reindexFromSource() {
|
||||
try {
|
||||
if (this.source == null) return java.util.List.of();
|
||||
return reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnitureTextProvider.reindexFromSource failed", e);
|
||||
return java.util.List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static Path resolveSource() {
|
||||
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve();
|
||||
if (source.ok()) return source.path();
|
||||
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message());
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh sanitized index, swap it in atomically, and return the
|
||||
* changed/added entries (sanitized) as the delta versus the previous index.
|
||||
*/
|
||||
public java.util.List<FurnidataEntry> reindex(java.util.List<FurnidataEntry> entries) {
|
||||
Map<String, FurniText> next = new HashMap<>(Math.max(16, entries.size() * 2));
|
||||
for (FurnidataEntry e : entries) {
|
||||
String key = baseKey(e.classname());
|
||||
if (key == null) continue;
|
||||
next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description())));
|
||||
}
|
||||
|
||||
Map<String, FurniText> prev = this.index;
|
||||
java.util.List<FurnidataEntry> delta = new java.util.ArrayList<>();
|
||||
for (Map.Entry<String, FurniText> en : next.entrySet()) {
|
||||
FurniText cur = en.getValue();
|
||||
FurniText old = prev.get(en.getKey());
|
||||
if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) {
|
||||
delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description()));
|
||||
}
|
||||
}
|
||||
|
||||
this.index = next; // atomic reference swap
|
||||
return delta;
|
||||
}
|
||||
|
||||
/** Returns the sanitized display name for a DB classname, or null if absent/disabled. */
|
||||
public String getName(String classname) {
|
||||
if (!this.enabled) return null;
|
||||
String key = baseKey(classname);
|
||||
if (key == null) return null;
|
||||
FurniText t = this.index.get(key);
|
||||
return (t != null) ? t.name() : null;
|
||||
}
|
||||
|
||||
private static String baseKey(String classname) {
|
||||
if (classname == null) return null;
|
||||
int star = classname.indexOf('*');
|
||||
String base = (star >= 0) ? classname.substring(0, star) : classname;
|
||||
base = base.trim().toLowerCase(Locale.ROOT);
|
||||
return base.isEmpty() ? null : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe).
|
||||
* The 256 cap is in Java {@code char} units (UTF-16 code units), which is acceptable for
|
||||
* furni names (controlled, predominantly ASCII source). Lone/astral surrogates are not
|
||||
* specially handled.
|
||||
*/
|
||||
public static String sanitize(String value) {
|
||||
if (value == null) return "";
|
||||
StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN));
|
||||
for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '%') { sb.append('%'); continue; } // fullwidth percent — not a placeholder token
|
||||
if (c == '\n' || c == '\r' || Character.isISOControl(c)) continue;
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all lowercased base classnames whose furnidata display name contains
|
||||
* {@code query} (case-insensitive, substring). Results are capped at 200 to
|
||||
* bound SQL IN-clause size. Returns an empty list when query is null/blank.
|
||||
*/
|
||||
public java.util.List<String> findClassnamesByName(String query) {
|
||||
java.util.List<String> out = new java.util.ArrayList<>();
|
||||
if (query == null) return out;
|
||||
String q = query.trim().toLowerCase(Locale.ROOT);
|
||||
if (q.isEmpty()) return out;
|
||||
Map<String, FurniText> idx = this.index; // local ref (volatile)
|
||||
for (Map.Entry<String, FurniText> e : idx.entrySet()) {
|
||||
FurniText t = e.getValue();
|
||||
if (t != null && t.name() != null && t.name().toLowerCase(Locale.ROOT).contains(q)) {
|
||||
out.add(e.getKey()); // key is the lowercased base classname
|
||||
if (out.size() >= 200) break; // bound IN-clause size
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private record FurniText(int id, FurnitureType type, String name, String description) {}
|
||||
}
|
||||
@@ -167,6 +167,20 @@ public class Item implements ISerialize {
|
||||
return this.fullName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for user-facing/log output, sourced from furnidata (by classname).
|
||||
* Falls back to the DB public_name when furnidata has no entry or names are disabled.
|
||||
* Never returns null.
|
||||
*/
|
||||
public String getDisplayName() {
|
||||
FurnitureTextProvider provider = (Emulator.getGameEnvironment() != null)
|
||||
? Emulator.getGameEnvironment().getFurnitureTextProvider()
|
||||
: null;
|
||||
String name = (provider != null) ? provider.getName(this.name) : null;
|
||||
if (name != null && !name.isBlank()) return name;
|
||||
return (this.fullName != null) ? this.fullName : "";
|
||||
}
|
||||
|
||||
public FurnitureType getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
+1
-1
@@ -279,7 +279,7 @@ public final class WiredTextPlaceholderUtil {
|
||||
continue;
|
||||
}
|
||||
|
||||
String furniName = item.getBaseItem().getFullName();
|
||||
String furniName = item.getBaseItem().getDisplayName();
|
||||
if (furniName == null || furniName.trim().isEmpty()) {
|
||||
furniName = item.getBaseItem().getName();
|
||||
}
|
||||
|
||||
@@ -285,6 +285,9 @@ public class PacketManager {
|
||||
this.registerHandler(Incoming.FurniEditorInteractionsEvent, FurniEditorInteractionsEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorImportTextEvent, FurniEditorImportTextEvent.class);
|
||||
|
||||
// Catalog Admin
|
||||
this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class);
|
||||
@@ -298,6 +301,8 @@ public class PacketManager {
|
||||
this.registerHandler(Incoming.CatalogAdminPublishEvent, CatalogAdminPublishEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminSavePageImagesEvent, CatalogAdminSavePageImagesEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminSavePageIconEvent, CatalogAdminSavePageIconEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminLoadOfferEvent, CatalogAdminLoadOfferEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminLoadPageEvent, CatalogAdminLoadPageEvent.class);
|
||||
}
|
||||
|
||||
private void registerEvent() throws Exception {
|
||||
|
||||
@@ -431,6 +431,9 @@ public class Incoming {
|
||||
public static final int FurniEditorInteractionsEvent = 10043;
|
||||
public static final int FurniEditorUpdateEvent = 10044;
|
||||
public static final int FurniEditorDeleteEvent = 10045;
|
||||
public static final int FurniEditorUpdateFurnidataEvent = 10046;
|
||||
public static final int FurniEditorRevertFurnidataEvent = 10048;
|
||||
public static final int FurniEditorImportTextEvent = 10049;
|
||||
|
||||
// Catalog Admin
|
||||
public static final int CatalogAdminSavePageEvent = 10050;
|
||||
@@ -444,6 +447,8 @@ public class Incoming {
|
||||
public static final int CatalogAdminPublishEvent = 10058;
|
||||
public static final int CatalogAdminSavePageImagesEvent = 10060;
|
||||
public static final int CatalogAdminSavePageIconEvent = 10061;
|
||||
public static final int CatalogAdminLoadOfferEvent = 10062;
|
||||
public static final int CatalogAdminLoadPageEvent = 10063;
|
||||
|
||||
// Custom Prefixes
|
||||
public static final int RequestUserPrefixesEvent = 7011;
|
||||
|
||||
+2
-2
@@ -248,7 +248,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
|
||||
LOGGER.debug("sender reached daily total LTD limit");
|
||||
this.client.getHabbo().alert(
|
||||
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total")
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getFullName())
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName())
|
||||
.replace("%limit%", ltdLimit + "")
|
||||
);
|
||||
return;
|
||||
@@ -259,7 +259,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
|
||||
LOGGER.debug("sender reached daily LTD item limit");
|
||||
this.client.getHabbo().alert(
|
||||
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item")
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getFullName())
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName())
|
||||
.replace("%limit%", ltdLimit + "")
|
||||
);
|
||||
return;
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminOfferDetailsComposer;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminResultComposer;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public class CatalogAdminLoadOfferEvent extends MessageHandler {
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
int offerId = this.packet.readInt();
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
|
||||
String sql = (pageType == CatalogPageType.BUILDER)
|
||||
? "SELECT id, order_number FROM catalog_items_bc WHERE id = ? LIMIT 1"
|
||||
: "SELECT id, offer_id, limited_stack, order_number FROM catalog_items WHERE id = ? LIMIT 1";
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, offerId);
|
||||
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
if (!set.next()) return;
|
||||
|
||||
if (pageType == CatalogPageType.BUILDER) {
|
||||
this.client.sendResponse(new CatalogAdminOfferDetailsComposer(
|
||||
set.getInt("id"),
|
||||
0,
|
||||
0,
|
||||
set.getInt("order_number")
|
||||
));
|
||||
} else {
|
||||
this.client.sendResponse(new CatalogAdminOfferDetailsComposer(
|
||||
set.getInt("id"),
|
||||
set.getInt("offer_id"),
|
||||
set.getInt("limited_stack"),
|
||||
set.getInt("order_number")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPage;
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminPageDetailsComposer;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminResultComposer;
|
||||
|
||||
public class CatalogAdminLoadPageEvent extends MessageHandler {
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
int pageId = this.packet.readInt();
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
|
||||
CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType);
|
||||
if (page == null) return;
|
||||
|
||||
this.client.sendResponse(new CatalogAdminPageDetailsComposer(page));
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ public class FurniEditorHelper {
|
||||
* Prevents SQL injection via arbitrary column names.
|
||||
*/
|
||||
public static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
|
||||
"item_name", "public_name", "sprite_id", "type", "width", "length",
|
||||
"public_name", "sprite_id", "type", "width", "length",
|
||||
"stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay",
|
||||
"allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell",
|
||||
"allow_inventory_stack", "interaction_type", "interaction_modes_count",
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorImportTextResultComposer;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Incoming 10049 — admin imports the official Habbo display name/description for a
|
||||
* furni's classname from a configured furnidata URL (e.g.
|
||||
* https://www.habbo.it/gamedata/furnidata_json/1). The fetched text only POPULATES
|
||||
* the editor fields client-side; the admin reviews and Saves via the normal flow.
|
||||
*
|
||||
* Source URL is admin-configured in emulator_settings ({@code furni.editor.import.url}),
|
||||
* never supplied by the client (no SSRF). The remote furnidata is cached with a TTL.
|
||||
*/
|
||||
public class FurniEditorImportTextEvent extends MessageHandler {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorImportTextEvent.class);
|
||||
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
|
||||
|
||||
// Shared TTL cache (the remote furnidata is multi-MB — do not refetch per click).
|
||||
private static volatile JsonObject CACHE;
|
||||
private static volatile String CACHE_URL;
|
||||
private static volatile long CACHE_TIME;
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
int itemId = this.packet.readInt();
|
||||
if (itemId <= 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
|
||||
if (classname == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
|
||||
return;
|
||||
}
|
||||
String cn = classname.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
String url = Emulator.getConfig().getValue(
|
||||
"furni.editor.import.url", "https://www.habbo.it/gamedata/furnidata_json/1");
|
||||
if (url == null || !(url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Import source not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject root = fetchCached(url);
|
||||
if (root == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Could not fetch Habbo furnidata"));
|
||||
return;
|
||||
}
|
||||
|
||||
String foundName = null, foundDesc = null;
|
||||
outer:
|
||||
for (String section : SECTIONS) {
|
||||
if (!root.has(section) || !root.get(section).isJsonObject()) continue;
|
||||
JsonObject sec = root.getAsJsonObject(section);
|
||||
if (!sec.has("furnitype") || !sec.get("furnitype").isJsonArray()) continue;
|
||||
for (JsonElement el : sec.getAsJsonArray("furnitype")) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
if (!o.has("classname")) continue;
|
||||
if (o.get("classname").getAsString().trim().toLowerCase(Locale.ROOT).equals(cn)) {
|
||||
foundName = (o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : "";
|
||||
foundDesc = (o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : "";
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean found = (foundName != null);
|
||||
this.client.sendResponse(new FurniEditorImportTextResultComposer(
|
||||
found, found ? foundName : "", found ? foundDesc : "", classname));
|
||||
LOGGER.info("FurniEditorImportTextEvent: admin {} import for classname '{}' (item {}) -> found={}",
|
||||
this.client.getHabbo().getHabboInfo().getId(), classname, itemId, found);
|
||||
}
|
||||
|
||||
/** Fetch the remote furnidata JSON with a TTL cache (serves stale on failure). */
|
||||
private static synchronized JsonObject fetchCached(String url) {
|
||||
long ttlMs;
|
||||
try {
|
||||
ttlMs = Long.parseLong(Emulator.getConfig().getValue("furni.editor.import.cache.ms", "600000"));
|
||||
} catch (Exception e) {
|
||||
ttlMs = 600000L;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (CACHE != null && url.equals(CACHE_URL) && (now - CACHE_TIME) < ttlMs) {
|
||||
return CACHE;
|
||||
}
|
||||
|
||||
try {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("User-Agent", "Arcturus-FurniEditor")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) {
|
||||
LOGGER.warn("FurniEditorImportTextEvent: fetch {} returned HTTP {}", url, resp.statusCode());
|
||||
return CACHE; // serve stale if available
|
||||
}
|
||||
JsonObject root = JsonParser.parseString(resp.body()).getAsJsonObject();
|
||||
CACHE = root;
|
||||
CACHE_URL = url;
|
||||
CACHE_TIME = now;
|
||||
return root;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurniEditorImportTextEvent: failed to fetch {}", url, e);
|
||||
return CACHE; // serve stale if available
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataLock;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataWriter;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Incoming handler 10048 — admin reverts a furni's furnidata to the last rotating backup.
|
||||
*
|
||||
* Flow: permission check → read item_id → resolve classname → under FurnidataLock:
|
||||
* FurnidataWriter.revertLastBackup → FurnitureTextProvider.reindexFromSource →
|
||||
* broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
|
||||
*/
|
||||
public class FurniEditorRevertFurnidataEvent extends MessageHandler {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorRevertFurnidataEvent.class);
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
Habbo habbo = this.client.getHabbo();
|
||||
|
||||
// 1. Permission check
|
||||
if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Read packet
|
||||
int itemId = this.packet.readInt();
|
||||
|
||||
if (itemId <= 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Resolve classname from item_id (reuse static helper from update handler)
|
||||
String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
|
||||
String classnameForLog = (classname != null) ? classname : "?";
|
||||
|
||||
// 4. Verify provider is configured
|
||||
FurnitureTextProvider provider =
|
||||
Emulator.getGameEnvironment().getFurnitureTextProvider();
|
||||
|
||||
if (provider == null || provider.getSource() == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
int adminId = habbo.getHabboInfo().getId();
|
||||
|
||||
// 5. Revert + reindex + broadcast under the shared lock
|
||||
boolean reverted;
|
||||
List<FurnidataEntry> delta;
|
||||
|
||||
FurnidataLock.LOCK.lock();
|
||||
try {
|
||||
FurnidataWriter writer = new FurnidataWriter(
|
||||
provider.getSource(),
|
||||
provider.isSourceDirectory(),
|
||||
provider.getMaxBytes(),
|
||||
3 /* backupKeep */
|
||||
);
|
||||
reverted = writer.revertLastBackup();
|
||||
if (!reverted) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No backup found to revert"));
|
||||
return;
|
||||
}
|
||||
|
||||
delta = provider.reindexFromSource();
|
||||
|
||||
if (!delta.isEmpty()) {
|
||||
int deltaCap = Integer.parseInt(
|
||||
Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
broadcastToAll(composer);
|
||||
}
|
||||
} finally {
|
||||
FurnidataLock.LOCK.unlock();
|
||||
}
|
||||
|
||||
// 6. Audit log (outside lock — DB write, not latency-sensitive)
|
||||
FurnidataAuditLog.record(
|
||||
adminId,
|
||||
classnameForLog,
|
||||
"revert",
|
||||
"", // previous state unknown at this point
|
||||
"",
|
||||
"",
|
||||
""
|
||||
);
|
||||
|
||||
// 7. Respond success
|
||||
this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata reverted", itemId));
|
||||
LOGGER.info("FurniEditorRevertFurnidataEvent: admin {} reverted furnidata for classname '{}' (item {})",
|
||||
adminId, classnameForLog, itemId);
|
||||
}
|
||||
|
||||
private static void broadcastToAll(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -27,6 +27,8 @@ public class FurniEditorSearchEvent extends MessageHandler {
|
||||
String query = this.packet.readString();
|
||||
String type = this.packet.readString();
|
||||
int page = this.packet.readInt();
|
||||
String sortField = this.packet.readString();
|
||||
String sortDir = this.packet.readString();
|
||||
|
||||
// Input validation
|
||||
if (query.length() > 100) {
|
||||
@@ -64,10 +66,53 @@ public class FurniEditorSearchEvent extends MessageHandler {
|
||||
params.add(type);
|
||||
}
|
||||
|
||||
// Extend search with furnidata display-name matches (server-authoritative names in JSON).
|
||||
// Appends: OR (LOWER(item_name) IN (?,?,...) [AND type=?])
|
||||
// Both branches carry their own type filter, so type scoping is preserved.
|
||||
// Params: [existing LIKE params] [existing type?] [furniCns...] [type again?]
|
||||
if (!query.isEmpty()) {
|
||||
java.util.List<String> furniCns = Emulator.getGameEnvironment()
|
||||
.getFurnitureTextProvider()
|
||||
.findClassnamesByName(query);
|
||||
if (!furniCns.isEmpty()) {
|
||||
// Build: OR (LOWER(item_name) IN (?,?,...) [AND type = ?])
|
||||
StringBuilder orBranch = new StringBuilder(" OR (LOWER(item_name) IN (");
|
||||
for (int i = 0; i < furniCns.size(); i++) {
|
||||
if (i > 0) orBranch.append(", ");
|
||||
orBranch.append('?');
|
||||
}
|
||||
orBranch.append(')');
|
||||
if (type != null && !type.isEmpty()) {
|
||||
orBranch.append(" AND type = ?");
|
||||
}
|
||||
orBranch.append(')');
|
||||
whereClause.append(orBranch);
|
||||
params.addAll(furniCns);
|
||||
if (type != null && !type.isEmpty()) {
|
||||
params.add(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a SAFE ORDER BY from the whitelisted sort field/direction
|
||||
// (column names are never taken from raw user input — injection-proof).
|
||||
String orderColumn;
|
||||
switch (sortField == null ? "" : sortField) {
|
||||
case "spriteId": orderColumn = "sprite_id"; break;
|
||||
case "itemName": orderColumn = "item_name"; break;
|
||||
case "publicName": orderColumn = "public_name"; break;
|
||||
case "type": orderColumn = "type"; break;
|
||||
case "interactionType": orderColumn = "interaction_type"; break;
|
||||
case "id":
|
||||
default: orderColumn = "id"; break;
|
||||
}
|
||||
String orderDir = "desc".equalsIgnoreCase(sortDir) ? "DESC" : "ASC";
|
||||
|
||||
// Count total
|
||||
int total = 0;
|
||||
String countSql = "SELECT COUNT(*) FROM items_base " + whereClause;
|
||||
String dataSql = "SELECT * FROM items_base " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?";
|
||||
String dataSql = "SELECT * FROM items_base " + whereClause
|
||||
+ " ORDER BY " + orderColumn + " " + orderDir + ", id ASC LIMIT ? OFFSET ?";
|
||||
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataLock;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataWriter;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Incoming handler 10046 — admin saves a furni name/description in the editor.
|
||||
*
|
||||
* Flow: permission check → rate-limit → resolve classname from item_id →
|
||||
* under FurnidataLock: FurnidataWriter.write → FurnitureTextProvider.reindexFromSource →
|
||||
* broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
|
||||
*/
|
||||
public class FurniEditorUpdateFurnidataEvent extends MessageHandler {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorUpdateFurnidataEvent.class);
|
||||
|
||||
/** Rate-limit: min milliseconds between successive calls per admin user id. */
|
||||
private static final long RATE_LIMIT_MS = 1_000L;
|
||||
|
||||
/** Per-admin last-call timestamp map. */
|
||||
private static final Map<Integer, Long> LAST_CALL = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
Habbo habbo = this.client.getHabbo();
|
||||
|
||||
// 1. Permission check
|
||||
if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Rate-limit per admin
|
||||
int adminId = habbo.getHabboInfo().getId();
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = LAST_CALL.get(adminId);
|
||||
if (last != null && (now - last) < RATE_LIMIT_MS) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Too many requests"));
|
||||
return;
|
||||
}
|
||||
LAST_CALL.put(adminId, now);
|
||||
|
||||
// 3. Read packet
|
||||
int itemId = this.packet.readInt();
|
||||
JsonObject json;
|
||||
try {
|
||||
json = JsonParser.parseString(this.packet.readString()).getAsJsonObject();
|
||||
} catch (Exception e) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid JSON data"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemId <= 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
String name = json.has("name") ? json.get("name").getAsString() : null;
|
||||
String description = json.has("description") ? json.get("description").getAsString() : null;
|
||||
|
||||
if (name == null && description == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No name or description provided"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Resolve classname from item_id
|
||||
String classname = classnameForItem(itemId);
|
||||
if (classname == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Write + reindex + broadcast under the shared lock
|
||||
FurnitureTextProvider provider =
|
||||
Emulator.getGameEnvironment().getFurnitureTextProvider();
|
||||
|
||||
if (provider == null || provider.getSource() == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture old values (before write) for the audit log
|
||||
String oldName = provider.getName(classname);
|
||||
// description is not indexed in the provider — treat as empty string for audit
|
||||
String oldDesc = "";
|
||||
|
||||
// FurnidataWriter.write() calls FurnitureTextProvider.sanitize() internally;
|
||||
// pass the raw values here and use them also for the audit log.
|
||||
String safeName = (name != null) ? name : "";
|
||||
String safeDesc = (description != null) ? description : "";
|
||||
|
||||
boolean written;
|
||||
List<FurnidataEntry> delta;
|
||||
|
||||
FurnidataLock.LOCK.lock();
|
||||
try {
|
||||
FurnidataWriter writer = new FurnidataWriter(
|
||||
provider.getSource(),
|
||||
provider.isSourceDirectory(),
|
||||
provider.getMaxBytes(),
|
||||
3 /* backupKeep */
|
||||
);
|
||||
written = writer.write(classname, safeName, safeDesc);
|
||||
if (!written) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata"));
|
||||
return;
|
||||
}
|
||||
|
||||
delta = provider.reindexFromSource();
|
||||
|
||||
if (!delta.isEmpty()) {
|
||||
int deltaCap = Integer.parseInt(
|
||||
Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
broadcastToAll(composer);
|
||||
}
|
||||
} finally {
|
||||
FurnidataLock.LOCK.unlock();
|
||||
}
|
||||
|
||||
// 5b. Auto-mirror the new display name into items_base.public_name (DB) so the
|
||||
// server-side fallback (Item.getFullName) and the editor's read-only
|
||||
// "Public Name" field stay in sync with the furnidata edit. Only when a
|
||||
// name was actually supplied (description-only edits must not blank it).
|
||||
// Kept outside FurnidataLock (independent DB write, like the audit log).
|
||||
if (name != null) {
|
||||
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement st = c.prepareStatement("UPDATE items_base SET public_name = ? WHERE id = ?")) {
|
||||
st.setString(1, FurnitureTextProvider.sanitize(safeName));
|
||||
st.setInt(2, itemId);
|
||||
st.executeUpdate();
|
||||
// Refresh the in-memory Item cache (Item.fullName) in place — no restart needed.
|
||||
Emulator.getGameEnvironment().getItemManager().loadItems();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to mirror furnidata name into items_base.public_name for item {}", itemId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Audit log (outside lock — DB write, not latency-sensitive)
|
||||
FurnidataAuditLog.record(
|
||||
adminId,
|
||||
classname,
|
||||
"edit",
|
||||
oldName != null ? oldName : "",
|
||||
FurnitureTextProvider.sanitize(safeName),
|
||||
oldDesc,
|
||||
FurnitureTextProvider.sanitize(safeDesc)
|
||||
);
|
||||
|
||||
// 7. Respond success
|
||||
this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata updated", itemId));
|
||||
LOGGER.info("FurniEditorUpdateFurnidataEvent: admin {} updated furnidata for classname '{}' (item {})",
|
||||
adminId, classname, itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the item_name (classname) from items_base for a given item id.
|
||||
* Kept static so FurniEditorRevertFurnidataEvent can reuse it.
|
||||
*
|
||||
* @return the classname string, or {@code null} if not found or on error.
|
||||
*/
|
||||
public static String classnameForItem(int itemId) {
|
||||
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement st = c.prepareStatement("SELECT item_name FROM items_base WHERE id = ?")) {
|
||||
st.setInt(1, itemId);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
if (rs.next()) return rs.getString("item_name");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("classnameForItem: failed to query items_base for id {}", itemId, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void broadcastToAll(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class FurnidataAuditLog {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataAuditLog.class);
|
||||
private FurnidataAuditLog() {}
|
||||
|
||||
public static void record(int userId, String classname, String action,
|
||||
String oldName, String newName, String oldDesc, String newDesc) {
|
||||
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement st = c.prepareStatement(
|
||||
"INSERT INTO furnidata_edit_log (user_id, classname, action, old_name, new_name, old_description, new_description, timestamp) " +
|
||||
"VALUES (?,?,?,?,?,?,?,?)")) {
|
||||
st.setInt(1, userId);
|
||||
st.setString(2, classname);
|
||||
st.setString(3, action);
|
||||
st.setString(4, oldName == null ? "" : oldName);
|
||||
st.setString(5, newName == null ? "" : newName);
|
||||
st.setString(6, oldDesc == null ? "" : oldDesc);
|
||||
st.setString(7, newDesc == null ? "" : newDesc);
|
||||
st.setInt(8, Emulator.getIntUnixTimestamp());
|
||||
st.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to write furnidata_edit_log", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,9 +570,13 @@ public class Outgoing {
|
||||
public static final int FurniEditorDetailComposer = 10041;
|
||||
public static final int FurniEditorInteractionsComposer = 10043;
|
||||
public static final int FurniEditorResultComposer = 10044;
|
||||
public static final int FurnitureDataReloadComposer = 10047; // CUSTOM
|
||||
public static final int FurniEditorImportTextResultComposer = 10049; // CUSTOM
|
||||
|
||||
// Catalog Admin
|
||||
public static final int CatalogAdminResultComposer = 10059;
|
||||
public static final int CatalogAdminOfferDetailsComposer = 10062;
|
||||
public static final int CatalogAdminPageDetailsComposer = 10063;
|
||||
|
||||
// Custom Prefixes
|
||||
public static final int UserPrefixesComposer = 7001;
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.eu.habbo.messages.outgoing.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class CatalogAdminOfferDetailsComposer extends MessageComposer {
|
||||
private final int offerId;
|
||||
private final int offerIdGroup;
|
||||
private final int limitedStack;
|
||||
private final int orderNumber;
|
||||
|
||||
public CatalogAdminOfferDetailsComposer(int offerId, int offerIdGroup, int limitedStack, int orderNumber) {
|
||||
this.offerId = offerId;
|
||||
this.offerIdGroup = offerIdGroup;
|
||||
this.limitedStack = limitedStack;
|
||||
this.orderNumber = orderNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.CatalogAdminOfferDetailsComposer);
|
||||
this.response.appendInt(this.offerId);
|
||||
this.response.appendInt(this.offerIdGroup);
|
||||
this.response.appendInt(this.limitedStack);
|
||||
this.response.appendInt(this.orderNumber);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.eu.habbo.messages.outgoing.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPage;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class CatalogAdminPageDetailsComposer extends MessageComposer {
|
||||
private final CatalogPage page;
|
||||
|
||||
public CatalogAdminPageDetailsComposer(CatalogPage page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.CatalogAdminPageDetailsComposer);
|
||||
this.response.appendInt(this.page.getId());
|
||||
this.response.appendString(this.page.getCaption());
|
||||
this.response.appendString(this.page.getPageName());
|
||||
this.response.appendInt(this.page.getRank());
|
||||
this.response.appendInt(this.page.getOrderNum());
|
||||
this.response.appendBoolean(this.page.isVisible());
|
||||
this.response.appendBoolean(this.page.isEnabled());
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class FriendsComposer extends MessageComposer {
|
||||
this.response.appendInt(row.getGender().equals(HabboGender.M) ? 0 : 1);
|
||||
this.response.appendBoolean(row.getOnline() == 1);
|
||||
this.response.appendBoolean(row.inRoom()); //IN ROOM
|
||||
this.response.appendString(row.getOnline() == 1 ? row.getLook() : "");
|
||||
this.response.appendString(row.getLook()); // send look for offline friends too (loaded from DB)
|
||||
this.response.appendInt(row.getCategoryId()); //Friends category
|
||||
this.response.appendString(row.getMotto());
|
||||
this.response.appendString(""); //Last seen as DATETIMESTRING
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.eu.habbo.messages.outgoing.furnieditor;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
/**
|
||||
* Outgoing 10049 — result of an "import texts from Habbo" request.
|
||||
* Carries the official furnidata name/description for a classname (or found=false).
|
||||
*/
|
||||
public class FurniEditorImportTextResultComposer extends MessageComposer {
|
||||
private final boolean found;
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final String classname;
|
||||
|
||||
public FurniEditorImportTextResultComposer(boolean found, String name, String description, String classname) {
|
||||
this.found = found;
|
||||
this.name = name == null ? "" : name;
|
||||
this.description = description == null ? "" : description;
|
||||
this.classname = classname == null ? "" : classname;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.FurniEditorImportTextResultComposer);
|
||||
this.response.appendBoolean(this.found);
|
||||
this.response.appendString(this.name);
|
||||
this.response.appendString(this.description);
|
||||
this.response.appendString(this.classname);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.eu.habbo.messages.outgoing.furniture;
|
||||
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureType;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class FurnitureDataReloadComposer extends MessageComposer {
|
||||
|
||||
public static final int MODE_DELTA = 0;
|
||||
public static final int MODE_RELOAD_HINT = 1;
|
||||
|
||||
private final int mode;
|
||||
private final List<FurnidataEntry> entries;
|
||||
|
||||
public FurnitureDataReloadComposer(int mode, List<FurnidataEntry> entries) {
|
||||
this.mode = mode;
|
||||
this.entries = entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.FurnitureDataReloadComposer);
|
||||
this.response.appendInt(this.mode);
|
||||
|
||||
if (this.mode == MODE_DELTA) {
|
||||
this.response.appendInt(this.entries.size());
|
||||
for (FurnidataEntry e : this.entries) {
|
||||
this.response.appendString(e.type() == FurnitureType.FLOOR ? "S" : "I");
|
||||
this.response.appendInt(e.id());
|
||||
this.response.appendString(e.classname());
|
||||
this.response.appendString(e.name());
|
||||
this.response.appendString(e.description());
|
||||
}
|
||||
}
|
||||
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ public class WatchAndEarnRewardComposer extends MessageComposer {
|
||||
this.response.appendString(this.item.getType().code);
|
||||
this.response.appendInt(this.item.getId());
|
||||
this.response.appendString(this.item.getName());
|
||||
this.response.appendString(this.item.getFullName());
|
||||
this.response.appendString(this.item.getDisplayName());
|
||||
return this.response;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.eu.habbo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SmokeTest {
|
||||
@Test
|
||||
void harnessRuns() {
|
||||
assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnidataReaderTest {
|
||||
|
||||
private static final String SINGLE = """
|
||||
{
|
||||
// a comment
|
||||
"roomitemtypes": { "furnitype": [
|
||||
{ "id": 10, "classname": "chair_norja", "name": "Chair", "description": "Sit", "xdim": 1, "ydim": 1 },
|
||||
]},
|
||||
"wallitemtypes": { "furnitype": [
|
||||
{ "id": 20, "classname": "poster_5", "name": "Poster", "description": "Wall" }
|
||||
]}
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void parsesSingleFileFloorAndWall(@TempDir Path dir) throws Exception {
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
|
||||
List<FurnidataEntry> entries = new FurnidataReader(file, 64 * 1024 * 1024).read();
|
||||
|
||||
assertEquals(2, entries.size());
|
||||
FurnidataEntry floor = entries.stream().filter(e -> e.id() == 10).findFirst().orElseThrow();
|
||||
assertEquals("chair_norja", floor.classname());
|
||||
assertEquals(FurnitureType.FLOOR, floor.type());
|
||||
assertEquals("Chair", floor.name());
|
||||
FurnidataEntry wall = entries.stream().filter(e -> e.id() == 20).findFirst().orElseThrow();
|
||||
assertEquals(FurnitureType.WALL, wall.type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFileOverSizeCap(@TempDir Path dir) throws Exception {
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
List<FurnidataEntry> entries = new FurnidataReader(file, 8 /* bytes */).read();
|
||||
assertTrue(entries.isEmpty(), "oversized file must be refused, returning empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSourceReturnsEmptyNeverThrows(@TempDir Path dir) {
|
||||
Path missing = dir.resolve("does-not-exist.json");
|
||||
assertDoesNotThrow(() -> {
|
||||
assertTrue(new FurnidataReader(missing, 64 * 1024 * 1024).read().isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedManifestIsSkippedNeverThrows(@TempDir Path dir) throws Exception {
|
||||
Path base = dir.resolve("furnidata");
|
||||
Path core = base.resolve("core");
|
||||
Files.createDirectories(core);
|
||||
// A root manifest larger than the cap we pass in.
|
||||
Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] } // padding ".repeat(50));
|
||||
List<FurnidataEntry> entries = new FurnidataReader(base, 8 /* bytes */).read();
|
||||
assertTrue(entries.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitDirRejectsTraversalFiles(@TempDir Path dir) throws Exception {
|
||||
Path secret = dir.resolve("secret.json");
|
||||
Files.writeString(secret, "{ \"roomitemtypes\": { \"furnitype\": [ { \"id\": 99, \"classname\": \"x\", \"name\": \"LEAK\", \"description\": \"\" } ] } }");
|
||||
|
||||
Path base = dir.resolve("furnidata");
|
||||
Path core = base.resolve("core");
|
||||
Files.createDirectories(core);
|
||||
Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] }");
|
||||
Files.writeString(core.resolve("manifest.json"), "{ \"files\": [ \"../../secret.json\" ] }");
|
||||
|
||||
List<FurnidataEntry> entries = new FurnidataReader(base, 64 * 1024 * 1024).read();
|
||||
|
||||
assertTrue(entries.stream().noneMatch(e -> e.id() == 99),
|
||||
"traversal file outside the base dir must be ignored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnidataWriterTest {
|
||||
|
||||
private static final String SINGLE =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
// Tier data: core has the entry with "core name"; custom ALSO has it with "custom old name".
|
||||
// The writer must pick the custom (winning) tier and leave core untouched.
|
||||
private static final String CORE_DATA =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"core name\", \"description\": \"core desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
private static final String CUSTOM_DATA =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"custom old name\", \"description\": \"custom old desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
@Test
|
||||
void writesNameAndDescriptionByClassnameSingleFile() throws Exception {
|
||||
Path dir = Files.createTempDirectory("fd");
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("01_caterhead", "Cat Head", "A cat head");
|
||||
|
||||
assertTrue(ok);
|
||||
String after = Files.readString(file);
|
||||
assertTrue(after.contains("\"Cat Head\""));
|
||||
assertTrue(after.contains("\"A cat head\""));
|
||||
assertFalse(after.contains("old name"));
|
||||
// backup created
|
||||
assertTrue(Files.list(dir).anyMatch(p -> p.getFileName().toString().startsWith("FurnitureData.json.bak")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownClassname() throws Exception {
|
||||
Path dir = Files.createTempDirectory("fd");
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||
assertFalse(w.write("does_not_exist", "x", "y"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split-tier: classname present in both core and custom tiers.
|
||||
* The writer must update the winning (later) tier — custom — and leave core untouched.
|
||||
*/
|
||||
@Test
|
||||
void splitTierWritesWinningTierLeavesEarlierTierUntouched() throws Exception {
|
||||
Path base = Files.createTempDirectory("fd-split");
|
||||
|
||||
// Tier subdirectories
|
||||
Path coreDir = base.resolve("core");
|
||||
Path customDir = base.resolve("custom");
|
||||
Files.createDirectories(coreDir);
|
||||
Files.createDirectories(customDir);
|
||||
|
||||
// Top-level manifest: tiers in override order (core < custom)
|
||||
Files.writeString(base.resolve("manifest.json"),
|
||||
"{ \"tiers\": [ \"core\", \"custom\" ] }");
|
||||
|
||||
// Per-tier manifests listing the data file
|
||||
Files.writeString(coreDir.resolve("manifest.json"),
|
||||
"{ \"files\": [ \"furnidata.json\" ] }");
|
||||
Files.writeString(customDir.resolve("manifest.json"),
|
||||
"{ \"files\": [ \"furnidata.json\" ] }");
|
||||
|
||||
// Data files
|
||||
Path coreFile = coreDir.resolve("furnidata.json");
|
||||
Path customFile = customDir.resolve("furnidata.json");
|
||||
Files.writeString(coreFile, CORE_DATA);
|
||||
Files.writeString(customFile, CUSTOM_DATA);
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("split_chair", "New Name", "New desc");
|
||||
|
||||
assertTrue(ok, "write must succeed for classname present in split-tier layout");
|
||||
|
||||
// custom (winning tier) must be updated
|
||||
String customAfter = Files.readString(customFile);
|
||||
assertTrue(customAfter.contains("\"New Name\""), "winning tier must contain new name");
|
||||
assertTrue(customAfter.contains("\"New desc\""), "winning tier must contain new desc");
|
||||
assertFalse(customAfter.contains("custom old name"), "old name must be gone from winning tier");
|
||||
|
||||
// core (earlier tier) must be UNTOUCHED
|
||||
String coreAfter = Files.readString(coreFile);
|
||||
assertTrue(coreAfter.contains("core name"), "earlier tier must be left untouched");
|
||||
}
|
||||
|
||||
/**
|
||||
* Split-tier path-traversal guard: a manifest that lists "../escape" as a tier
|
||||
* must be rejected by safeResolve so the writer cannot reach files outside the base dir.
|
||||
*/
|
||||
@Test
|
||||
void splitTierRejectsTraversalTierInManifest() throws Exception {
|
||||
Path base = Files.createTempDirectory("fd-traversal");
|
||||
|
||||
// "Escape" directory sits OUTSIDE base
|
||||
Path escapeDir = base.getParent().resolve("escape_secret");
|
||||
Files.createDirectories(escapeDir);
|
||||
Files.writeString(escapeDir.resolve("manifest.json"),
|
||||
"{ \"files\": [ \"secret.json\" ] }");
|
||||
Files.writeString(escapeDir.resolve("secret.json"),
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 99, \"classname\": \"escape_chair\", \"name\": \"secret old\", \"description\": \"\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }");
|
||||
|
||||
// Top-level manifest references the escape dir via traversal
|
||||
Files.writeString(base.resolve("manifest.json"),
|
||||
"{ \"tiers\": [ \"../escape_secret\" ] }");
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("escape_chair", "Pwned", "desc");
|
||||
|
||||
assertFalse(ok, "classname reachable only via traversal path must not be found/written");
|
||||
|
||||
// The secret file must not have been touched
|
||||
String secretAfter = Files.readString(escapeDir.resolve("secret.json"));
|
||||
assertTrue(secretAfter.contains("secret old"), "traversal target must be untouched");
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnitureTextProviderDeltaTest {
|
||||
|
||||
@Test
|
||||
void firstReindexReturnsAllAsDelta() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||
assertEquals(1, delta.size());
|
||||
assertEquals("Chair", delta.get(0).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedReindexReturnsEmptyDelta() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
List<FurnidataEntry> first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"));
|
||||
p.reindex(first);
|
||||
List<FurnidataEntry> delta = p.reindex(first);
|
||||
assertTrue(delta.isEmpty(), "no change => empty delta");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedNameAppearsInDeltaWithSanitizedValue() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
p.reindex(List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "New %x%", "Sit")));
|
||||
assertEquals(1, delta.size());
|
||||
assertFalse(delta.get(0).name().contains("%"), "delta carries the sanitized name");
|
||||
assertEquals(1, delta.get(0).id());
|
||||
assertEquals(FurnitureType.FLOOR, delta.get(0).type());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnitureTextProviderTest {
|
||||
|
||||
private FurnitureTextProvider provider(boolean enabled, FurnidataEntry... entries) {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(enabled);
|
||||
p.reindex(List.of(entries));
|
||||
return p;
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesNameByClassname() {
|
||||
FurnitureTextProvider p = provider(true,
|
||||
new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||
assertEquals("Norja Chair", p.getName("chair_norja"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesBaseClassnameIgnoringColourVariantAndCase() {
|
||||
FurnitureTextProvider p = provider(true,
|
||||
new FurnidataEntry(1, "chair_norja*2", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||
assertEquals("Norja Chair", p.getName("CHAIR_NORJA"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenClassnameMissing() {
|
||||
FurnitureTextProvider p = provider(true);
|
||||
assertNull(p.getName("unknown_thing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenDisabled() {
|
||||
FurnitureTextProvider p = provider(false,
|
||||
new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||
assertNull(p.getName("chair_norja"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizesNameCapStripControlAndNeutralizesPercent() {
|
||||
String evil = "Bad\nName %limit% %user.name%".repeat(20);
|
||||
FurnitureTextProvider p = provider(true,
|
||||
new FurnidataEntry(1, "x", FurnitureType.FLOOR, evil, ""));
|
||||
String name = p.getName("x");
|
||||
assertEquals(256, name.length(), "input far exceeds the cap, so it must be exactly 256");
|
||||
assertFalse(name.chars().anyMatch(Character::isISOControl), "no control chars remain after sanitize");
|
||||
assertFalse(name.contains("%"), "ASCII percent neutralized");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullProviderNameNeverThrows() {
|
||||
FurnitureTextProvider p = provider(true);
|
||||
assertDoesNotThrow(() -> p.getName(null));
|
||||
assertNull(p.getName(null));
|
||||
}
|
||||
}
|
||||
+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