feat(furni-editor): create furnidata entry when missing (upsert on 10046)

FurniEditorUpdateFurnidataEvent (10046) was edit-only: FurnidataWriter.write()
refuses classnames absent from furnidata, so a furni with no entry showed the
DB-fallback name with locked fields and "Classname not found". Make it an upsert:

- FurnidataWriter.create(): append a complete entry (JSON5-preserving, atomic +
  backup) into the matching roomitemtypes/wallitemtypes furnitype array; guards
  against duplicate classname (ALREADY_EXISTS) and id collision (ID_COLLISION);
  split-tier writes to items.furnidata.create_tier (default "custom", file
  created with a shell if absent), single-file writes to the source.
- FurnidataEntryBuilder: build the complete entry from the item's items_base row
  (id = sprite id, classname, type-driven section, xdim/ydim, canstandon/
  cansiton/canlayon, name/desc, sane defaults matching existing entries).
- Handler: on write()==false, load the Item, build + create the entry, map
  CreateResult to a precise message; then the existing reindex + 10047 broadcast
  + public_name mirror run for both paths; audit action is "create" vs "edit".

No renderer change, no new packet. Pairs with the client unlocking name/desc when
the entry is missing (separate Nitro-V3 change).
This commit is contained in:
simoleo89
2026-06-13 17:59:48 +02:00
parent 93e5ea15aa
commit 2bc4340ec9
3 changed files with 179 additions and 3 deletions
@@ -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();
}
}
@@ -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.<id>} / 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 <section>.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) {
@@ -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<FurnidataEntry> 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,