diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java new file mode 100644 index 00000000..53f9b626 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java @@ -0,0 +1,52 @@ +package com.eu.habbo.habbohotel.items; + +/** + * Builds a complete furnidata entry object (single-line JSON5) from an {@link Item} + * (its items_base row) plus a display name/description. Used by the Furni Editor + * upsert path when a furni has no furnidata entry yet. Field shape mirrors the + * hotel's existing furnidata entries; {@code id} is the item's sprite id so the + * renderer resolves the furni's name/data by typeId. + */ +public final class FurnidataEntryBuilder { + + private FurnidataEntryBuilder() {} + + public static String build(Item item, String name, String description) { + String classname = item.getName() != null ? item.getName() : ""; + String safeName = (name != null && !name.isBlank()) ? name + : (item.getFullName() != null && !item.getFullName().isBlank()) ? item.getFullName() + : classname; + String safeDesc = description != null ? description : ""; + String customParams = item.getCustomParams() != null ? item.getCustomParams() : ""; + + StringBuilder b = new StringBuilder(256); + b.append("{\"id\":").append(item.getSpriteId()); + b.append(",\"classname\":\"").append(esc(classname)).append('"'); + b.append(",\"revision\":0,\"category\":\"unknown\",\"defaultdir\":0"); + b.append(",\"xdim\":").append(item.getWidth()); + b.append(",\"ydim\":").append(item.getLength()); + b.append(",\"partcolors\":{\"color\":[]}"); + b.append(",\"name\":\"").append(esc(safeName)).append('"'); + b.append(",\"description\":\"").append(esc(safeDesc)).append('"'); + b.append(",\"adurl\":\"\",\"offerid\":-1,\"buyout\":false,\"rentofferid\":-1,\"rentbuyout\":false,\"bc\":false,\"excludeddynamic\":false"); + b.append(",\"customparams\":\"").append(esc(customParams)).append('"'); + b.append(",\"specialtype\":1"); + b.append(",\"canstandon\":").append(item.allowWalk()); + b.append(",\"cansiton\":").append(item.allowSit()); + b.append(",\"canlayon\":").append(item.allowLay()); + b.append('}'); + return b.toString(); + } + + /** Escape for a JSON string value; collapse control chars to spaces. */ + private static String esc(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 if (c == '\n' || c == '\r' || c == '\t') b.append(' '); + else b.append(c); + } + return b.toString(); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java index fd4f701a..d0778c3f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java @@ -56,6 +56,98 @@ public class FurnidataWriter { return true; } + /** Outcome of a {@link #create} attempt. */ + public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR } + + /** + * Append a brand-new furnidata entry (upsert's "create" half). Refuses if the + * classname already exists (caller should edit instead) or if {@code id} is + * already used by a DIFFERENT classname (id collision would break the + * {@code roomItem.name.} / typeId resolution on the renderer). The complete + * entry object is built by the caller (see FurnidataEntryBuilder) and inserted + * right after the opening '[' of the matching section's "furnitype" array. + * + * @param classname new classname (must be absent from furnidata) + * @param id furnidata id (= item sprite id); must not collide + * @param type FLOOR -> roomitemtypes, WALL -> wallitemtypes + * @param entryJson5 the complete entry object as a single-line JSON5 string + * @param createTier split-tier only: the tier dir to write into (e.g. "custom"); ignored for single-file + */ + public CreateResult create(String classname, int id, FurnitureType type, String entryJson5, String createTier) { + String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT); + if (cn.isEmpty() || entryJson5 == null || entryJson5.isBlank()) return CreateResult.NO_TARGET; + + // Guard: duplicate classname / id collision (scan the whole source). + for (FurnidataEntry e : new FurnidataReader(source, maxBytes).read()) { + String ecn = e.classname() == null ? "" : e.classname().trim().toLowerCase(java.util.Locale.ROOT); + if (ecn.equals(cn)) return CreateResult.ALREADY_EXISTS; + if (e.id() == id) return CreateResult.ID_COLLISION; + } + + try { + Path target = resolveCreateTarget(createTier); + if (target == null) return CreateResult.NO_TARGET; + + String raw = Files.readString(target, StandardCharsets.UTF_8); + String section = (type == FurnitureType.WALL) ? "wallitemtypes" : "roomitemtypes"; + int open = furnitypeArrayOpenIndex(raw, section); + if (open < 0) return CreateResult.NO_TARGET; // section/array absent in target file + + String edited = raw.substring(0, open) + "\n" + entryJson5 + "," + raw.substring(open); + backup(target); + atomicWrite(target, edited); + return CreateResult.CREATED; + } catch (IOException e) { + return CreateResult.IO_ERROR; + } + } + + /** Single-file: the source. Split-tier: the create-tier file (created with a shell if absent). */ + private Path resolveCreateTarget(String createTier) throws IOException { + if (!directory) return source; + String tier = (createTier == null || createTier.isBlank()) ? "custom" : createTier.trim(); + Path base = source.toAbsolutePath().normalize(); + Path tierDir = safeResolve(base, tier); + if (tierDir == null) return null; + if (!Files.isDirectory(tierDir)) Files.createDirectories(tierDir); + for (String fileName : manifestList(tierDir, "files", List.of())) { + Path f = safeResolve(base, tierDir.resolve(fileName).toString()); + if (f != null && Files.isRegularFile(f)) return f; + } + Path def = tierDir.resolve("furnidata.json5"); + if (!Files.exists(def)) { + Files.writeString(def, + "{\n \"roomitemtypes\": { \"furnitype\": [\n] },\n \"wallitemtypes\": { \"furnitype\": [\n] }\n}\n", + StandardCharsets.UTF_8); + } + return def; + } + + /** Index just after the '[' that opens {@code
.furnitype}, or -1 if absent. String-aware. */ + static int furnitypeArrayOpenIndex(String raw, String section) { + int s = indexOfKey(raw, section, 0); + if (s < 0) return -1; + int ft = indexOfKey(raw, "furnitype", s); + if (ft < 0) return -1; + boolean inStr = false; char q = 0; + for (int i = ft; i < raw.length(); i++) { + char c = raw.charAt(i); + if (inStr) { if (c == '\\') i++; else if (c == q) inStr = false; continue; } + if (c == '"' || c == '\'') { inStr = true; q = c; } + else if (c == '[') return i + 1; + } + return -1; + } + + /** First occurrence of a quoted key ("key" or 'key') at/after {@code from}, or -1. */ + private static int indexOfKey(String raw, String key, int from) { + int a = raw.indexOf("\"" + key + "\"", from); + int b = raw.indexOf("'" + key + "'", from); + if (a < 0) return b; + if (b < 0) return a; + return Math.min(a, b); + } + /** 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) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java index fcdea56c..1d287261 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java @@ -5,6 +5,8 @@ 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.items.Item; +import com.eu.habbo.habbohotel.items.FurnidataEntryBuilder; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.messages.incoming.MessageHandler; @@ -109,6 +111,7 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { String safeDesc = (description != null) ? description : ""; boolean written; + boolean created = false; List delta; FurnidataLock.LOCK.lock(); @@ -121,8 +124,37 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { ); written = writer.write(classname, safeName, safeDesc); if (!written) { - this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata")); - return; + // Upsert: no furnidata entry for this classname yet → create a + // complete one seeded from items_base (id = sprite id). + Item item = Emulator.getGameEnvironment().getItemManager().getItem(itemId); + if (item == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found")); + return; + } + String createTier = Emulator.getConfig().getValue("items.furnidata.create_tier", "custom"); + String entry = FurnidataEntryBuilder.build( + item, + FurnitureTextProvider.sanitize(safeName), + FurnitureTextProvider.sanitize(safeDesc)); + FurnidataWriter.CreateResult cr = + writer.create(item.getName(), item.getSpriteId(), item.getType(), entry, createTier); + switch (cr) { + case CREATED: + created = true; + written = true; + break; + case ALREADY_EXISTS: + // entry already present (race / no-op edit) — apply the edit and treat as success + writer.write(classname, safeName, safeDesc); + written = true; + break; + case ID_COLLISION: + this.client.sendResponse(new FurniEditorResultComposer(false, "Sprite id already used by another classname")); + return; + default: + this.client.sendResponse(new FurniEditorResultComposer(false, "Failed to create furnidata entry")); + return; + } } delta = provider.reindexFromSource(); @@ -161,7 +193,7 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { FurnidataAuditLog.record( adminId, classname, - "edit", + created ? "create" : "edit", oldName != null ? oldName : "", FurnitureTextProvider.sanitize(safeName), oldDesc,