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/FurnidataSourceResolver.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java index 6d1f758f..85707871 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java @@ -36,30 +36,36 @@ public final class 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"); + return resolveConfigured(override, rendererConfigPath, assetBasePath); } 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 resolveConfigured(String legacyOverridePath, String rendererConfigPath, String assetBasePath) { + if (rendererConfigPath != null && !rendererConfigPath.isEmpty()) { + Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath == null || assetBasePath.isEmpty() ? null : Paths.get(assetBasePath)); + if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer; + } + + Source fromAssetBase = resolveFromAssetBase(assetBasePath); + if (fromAssetBase != null && fromAssetBase.ok()) return fromAssetBase; + + if (legacyOverridePath != null && !legacyOverridePath.isEmpty()) { + Path p = Paths.get(legacyOverridePath); + if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path fallback"); + return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path fallback does not exist"); + } + + if (fromAssetBase != null) return fromAssetBase; + + return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found"); + } + public static Source resolveFromRendererConfig(Path rendererConfig, Path assetBase) { try { if (rendererConfig == null || !Files.exists(rendererConfig)) { 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/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 6e9c6c92..df333dc7 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -27,6 +27,7 @@ public class FurnitureTextProvider { private final boolean enabled; private volatile Map index = Map.of(); private volatile Path source; + private volatile String sourceDescription = "unknown"; private FurnidataWatcher watcher; public FurnitureTextProvider(boolean enabled) { @@ -47,7 +48,7 @@ public class FurnitureTextProvider { return; } reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read()); - LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source); + LOGGER.info("Furniture Text Provider -> Indexed! ({} names, source: {})", this.index.size(), this.sourceDescription); if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) { if (this.watcher != null) this.watcher.stop(); @@ -88,9 +89,12 @@ public class FurnitureTextProvider { } } - private static Path resolveSource() { + private Path resolveSource() { FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve(); - if (source.ok()) return source.path(); + if (source.ok()) { + this.sourceDescription = source.message(); + return source.path(); + } LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message()); return null; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java index b31194dd..64c745bd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java @@ -57,8 +57,8 @@ public class FurniEditorDeleteEvent extends MessageHandler { // Check catalog_items references int catalogCount = 0; try (PreparedStatement stmt = connection.prepareStatement( - "SELECT COUNT(*) FROM catalog_items WHERE item_ids LIKE ?")) { - stmt.setString(1, "%" + id + "%"); + "SELECT COUNT(*) FROM catalog_items WHERE " + FurniEditorHelper.catalogItemIdsTokenSql("item_ids"))) { + stmt.setString(1, FurniEditorHelper.catalogItemIdsTokenPattern(id)); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { catalogCount = rs.getInt(1); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java index c4917fd1..e559633e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java @@ -75,8 +75,8 @@ public class FurniEditorDetailEvent extends MessageHandler { "ci.page_id AS ci_page_id, COALESCE(cp.caption, '') AS page_caption " + "FROM catalog_items ci " + "LEFT JOIN catalog_pages cp ON ci.page_id = cp.id " + - "WHERE ci.item_ids LIKE ?")) { - stmt.setString(1, "%" + itemId + "%"); + "WHERE " + FurniEditorHelper.catalogItemIdsTokenSql("ci.item_ids"))) { + stmt.setString(1, FurniEditorHelper.catalogItemIdsTokenPattern(itemId)); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { catalogItems.add(FurniEditorHelper.readCatalogRef(rs)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java index cd7f7a82..94a8305f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java @@ -11,6 +11,15 @@ import java.util.Map; * FurniEditorSearchEvent to ensure consistent field reading. */ public class FurniEditorHelper { + public static final String CATALOG_ITEM_IDS_TOKEN_SQL = "CONCAT(',', REPLACE(item_ids, ' ', ''), ',') LIKE ?"; + + public static String catalogItemIdsTokenSql(String column) { + return "CONCAT(',', REPLACE(" + column + ", ' ', ''), ',') LIKE ?"; + } + + public static String catalogItemIdsTokenPattern(int itemId) { + return "%," + itemId + ",%"; + } /** * Read the 14 base fields from items_base into a Map. diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java index 845395f5..e0b14f94 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java @@ -4,15 +4,11 @@ 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.FurniEditorResultComposer; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.sql.Connection; import java.sql.PreparedStatement; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; public class FurniEditorUpdateEvent extends MessageHandler { @@ -39,57 +35,18 @@ public class FurniEditorUpdateEvent extends MessageHandler { return; } - if (json.size() == 0) { - this.client.sendResponse(new FurniEditorResultComposer(false, "No fields to update")); + FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(json); + if (!payload.valid()) { + this.client.sendResponse(new FurniEditorResultComposer(false, payload.error)); return; } - // Build dynamic UPDATE with whitelisted fields - StringBuilder setClauses = new StringBuilder(); - List values = new ArrayList<>(); - - for (Map.Entry entry : json.entrySet()) { - String jsKey = entry.getKey(); - String dbColumn = FurniEditorHelper.FIELD_MAP.get(jsKey); - - if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) { - continue; // Skip unknown or disallowed fields - } - - if (setClauses.length() > 0) setClauses.append(", "); - setClauses.append("`").append(dbColumn).append("` = ?"); - - JsonElement val = entry.getValue(); - if (val.isJsonPrimitive()) { - if (val.getAsJsonPrimitive().isBoolean()) { - values.add(val.getAsBoolean() ? "1" : "0"); - } else if (val.getAsJsonPrimitive().isNumber()) { - // Check if it's a decimal number - String numStr = val.getAsString(); - if (numStr.contains(".")) { - values.add(val.getAsDouble()); - } else { - values.add(val.getAsInt()); - } - } else { - values.add(val.getAsString()); - } - } else { - values.add(val.toString()); - } - } - - if (setClauses.length() == 0) { - this.client.sendResponse(new FurniEditorResultComposer(false, "No valid fields to update")); - return; - } - - String sql = "UPDATE items_base SET " + setClauses + " WHERE id = ?"; + String sql = "UPDATE items_base SET " + payload.setClauses + " WHERE id = ?"; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement stmt = connection.prepareStatement(sql)) { int idx = 1; - for (Object value : values) { + for (Object value : payload.values) { if (value instanceof Integer) { stmt.setInt(idx++, (Integer) value); } else if (value instanceof Double) { @@ -99,7 +56,10 @@ public class FurniEditorUpdateEvent extends MessageHandler { } } stmt.setInt(idx, id); - stmt.executeUpdate(); + if (stmt.executeUpdate() == 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found: " + id)); + return; + } } // Reload emulator item definitions 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, diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java new file mode 100644 index 00000000..3dfb1d80 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java @@ -0,0 +1,133 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class FurniEditorUpdatePayload { + public final String setClauses; + public final List values; + public final String error; + + private FurniEditorUpdatePayload(String setClauses, List values, String error) { + this.setClauses = setClauses; + this.values = values; + this.error = error; + } + + public static FurniEditorUpdatePayload validate(JsonObject json) { + if (json == null || json.size() == 0) { + return invalid("No fields to update"); + } + + StringBuilder setClauses = new StringBuilder(); + List values = new ArrayList<>(); + + for (Map.Entry entry : json.entrySet()) { + String dbColumn = FurniEditorHelper.FIELD_MAP.get(entry.getKey()); + if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) { + continue; + } + + Object value = validateValue(dbColumn, entry.getValue()); + if (value == null) { + return invalid("Invalid value for " + entry.getKey()); + } + + if (setClauses.length() > 0) setClauses.append(", "); + setClauses.append("`").append(dbColumn).append("` = ?"); + values.add(value); + } + + if (setClauses.length() == 0) { + return invalid("No valid fields to update"); + } + + return new FurniEditorUpdatePayload(setClauses.toString(), values, null); + } + + public boolean valid() { + return this.error == null; + } + + private static FurniEditorUpdatePayload invalid(String error) { + return new FurniEditorUpdatePayload("", List.of(), error); + } + + private static Object validateValue(String dbColumn, JsonElement element) { + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + + JsonPrimitive primitive = element.getAsJsonPrimitive(); + return switch (dbColumn) { + case "public_name" -> boundedString(primitive, 0, 56); + case "type" -> itemType(primitive); + case "width", "length" -> boundedInt(primitive, 0, 64); + case "stack_height" -> boundedDouble(primitive, 0.0D, 99.99D); + case "allow_stack", "allow_walk", "allow_sit", "allow_lay", "allow_gift", + "allow_trade", "allow_recycle", "allow_marketplace_sell", "allow_inventory_stack" -> booleanFlag(primitive); + case "interaction_type" -> boundedString(primitive, 0, 500); + case "interaction_modes_count" -> boundedInt(primitive, 0, 100); + case "vending_ids", "clothing_on_walk" -> boundedString(primitive, 0, 255); + case "customparams" -> boundedString(primitive, 0, 256); + case "multiheight" -> boundedString(primitive, 0, 50); + case "effect_id_male", "effect_id_female", "sprite_id" -> boundedInt(primitive, 0, Integer.MAX_VALUE); + case "description" -> boundedString(primitive, 0, 500); + default -> null; + }; + } + + private static String boundedString(JsonPrimitive primitive, int minLength, int maxLength) { + if (!primitive.isString()) return null; + String value = primitive.getAsString(); + if (value.length() < minLength || value.length() > maxLength) return null; + return value; + } + + private static String itemType(JsonPrimitive primitive) { + String value = boundedString(primitive, 1, 3); + if (value == null) return null; + return value.matches("[a-z]+") ? value : null; + } + + private static Integer boundedInt(JsonPrimitive primitive, int min, int max) { + try { + int value = primitive.getAsInt(); + return value >= min && value <= max ? value : null; + } catch (Exception e) { + return null; + } + } + + private static Double boundedDouble(JsonPrimitive primitive, double min, double max) { + try { + double value = primitive.getAsDouble(); + return Double.isFinite(value) && value >= min && value <= max ? value : null; + } catch (Exception e) { + return null; + } + } + + private static String booleanFlag(JsonPrimitive primitive) { + if (primitive.isBoolean()) { + return primitive.getAsBoolean() ? "1" : "0"; + } + + if (primitive.isNumber()) { + int value = primitive.getAsInt(); + return value == 0 || value == 1 ? String.valueOf(value) : null; + } + + if (primitive.isString()) { + String value = primitive.getAsString(); + return "0".equals(value) || "1".equals(value) ? value : null; + } + + return null; + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java index fa3db546..32770ae3 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java @@ -78,4 +78,33 @@ class FurniDataManagerTest { assertEquals(assetBase.resolve("gamedata").resolve("FurnitureData.json"), source.path()); assertFalse(source.directory()); } + + @Test + void prefersRendererConfigOverLegacyFurnidataPath(@TempDir Path dir) throws Exception { + Path legacy = dir.resolve("legacy").resolve("FurnitureData.json"); + Files.createDirectories(legacy.getParent()); + Files.writeString(legacy, "{}"); + + Path assetBase = dir.resolve("nitro-assets"); + Path rendererSource = assetBase.resolve("gamedata").resolve("FurnitureData.json"); + Files.createDirectories(rendererSource.getParent()); + Files.writeString(rendererSource, "{}"); + + Path rendererConfig = dir.resolve("renderer-config.json"); + Files.writeString(rendererConfig, """ + { + "gamedata.url": "http://localhost:5173/nitro-assets/gamedata", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%" + } + """); + + FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolveConfigured( + legacy.toString(), + rendererConfig.toString(), + assetBase.toString()); + + assertTrue(source.ok()); + assertEquals(rendererSource, source.path()); + assertEquals("renderer-config furnidata.url", source.message()); + } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java new file mode 100644 index 00000000..de0ba5f5 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java @@ -0,0 +1,52 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FurniEditorUpdatePayloadTest { + @Test + void acceptsSafeEditorFields() { + FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(JsonParser.parseString(""" + { + "publicName": "Rare Chair", + "type": "s", + "width": 2, + "length": 1, + "stackHeight": 1.5, + "allowTrade": true, + "interactionModesCount": 3 + } + """).getAsJsonObject()); + + assertTrue(payload.valid()); + assertEquals(7, payload.values.size()); + } + + @Test + void rejectsOutOfRangeAndOversizedFields() { + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"width\":-1}").getAsJsonObject()).valid()); + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"stackHeight\":1000}").getAsJsonObject()).valid()); + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"allowTrade\":2}").getAsJsonObject()).valid()); + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"publicName\":\"" + "x".repeat(57) + "\"}").getAsJsonObject()).valid()); + } + + @Test + void ignoresUnknownFieldsButRequiresAtLeastOneValidField() { + FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate( + JsonParser.parseString("{\"itemName\":\"blocked\",\"unknown\":true}").getAsJsonObject()); + + assertFalse(payload.valid()); + assertEquals("No valid fields to update", payload.error); + } + + @Test + void buildsCatalogItemIdsTokenPattern() { + assertEquals("%,12,%", FurniEditorHelper.catalogItemIdsTokenPattern(12)); + assertTrue((",112,12,13,").contains(",12,")); + assertFalse((",112,13,").contains(",12,")); + } +} diff --git a/docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md b/docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md new file mode 100644 index 00000000..631a292d --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md @@ -0,0 +1,118 @@ +# Furnidata create-if-missing (upsert) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement task-by-task. Steps use checkbox (`- [ ]`). + +**Goal:** Let the Furni Editor create a complete furnidata entry for a furni that has none, by making the existing `FurniEditorUpdateFurnidata` (10046) handler an upsert. + +**Architecture:** Reuse packet 10046 (no renderer changes, no new packet). Emulator: new `FurnidataWriter.create(...)` (JSON5-preserving append) + handler routes "classname missing → create complete entry from `items_base`" + config key. Client: unlock name/desc when the entry is missing and relabel Save to "Create entry". + +**Tech Stack:** Java 21 (Arcturus emulator), Gson/JSON5, React/TS (Nitro-V3 client). + +**Spec:** `docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md` + +**Environment note:** On this machine furnidata is a SINGLE file `Nitro-Files/nitro-assets/gamedata/FurnitureData.json` (`FurnitureTextProvider.isSourceDirectory()==false`). Plan must also handle split-tier (directory) since the code supports it. + +--- + +## File structure + +- Modify: `Emulator/.../habbohotel/items/FurnidataWriter.java` — add `create(...)` + a `CreateResult` enum. +- Create: `Emulator/.../habbohotel/items/FurnidataEntryBuilder.java` — maps an `items_base` row → a furnidata JSON5 object string (floor/wall). +- Modify: `Emulator/.../messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java` — upsert routing. +- Create: `Emulator/src/test/.../FurnidataWriterCreateTest.java` — unit test for create(). +- Modify (client): `ui/src/components/furni-editor/views/FurniEditorEditView.tsx` — unlock + relabel + re-fetch. +- Config: `items.furnidata.create_tier` (default `custom`) read in the handler/writer; documented in the spec. + +--- + +### Task 1: Lock the furnidata field map (investigation, no code) + +**Files:** read-only. + +- [ ] **Step 1:** Read the exact `items_base` columns: `grep -n "items_base" Emulator/.../habbohotel/items/ItemManager.java` then read the `Item` constructor that consumes `SELECT * FROM items_base` (`Item.java`) to list columns (expected: `id`, `sprite_id`, `public_name`, `item_name`, `width`, `length`, `stack_height`, `allow_stack`, `allow_sit`, `allow_walk`, `allow_lay`, `type`, `interaction_type`, …). +- [ ] **Step 2:** Read the renderer floor/wall entry parse to confirm which furnidata fields matter: `renderer/packages/.../FurnitureData.ts` (or wherever `FurnitureDataLoader.parseFloorItems` builds a `FurnitureData`). Note the fields it reads (id, classname, revision, category, name, description, adurl, offerId, buyout, rentOfferId, rentBuyout, bc, excludedDynamic, customParams, specialType, canStandOn, canSitOn, canLayOn, furniLine, environment, rare, + dimensions xdim/ydim). +- [ ] **Step 3:** Write the mapping table into this plan file under Task 3 (replace the TABLE-PENDING marker). Mapping (defaults in parens for fields with no items_base source): + - `id` ← `items_base.sprite_id` ; `classname` ← `items_base.item_name` ; section `roomitemtypes`(floor)/`wallitemtypes`(wall) ← `items_base.type` (`s`/`i`) + - `name` ← submitted name (fallback `public_name`→`item_name`) ; `description` ← submitted desc + - `xdim` ← `width` ; `ydim` ← `length` ; `canstandon` ← `allow_walk` ; `cansiton` ← `allow_sit` ; `canlayon` ← `allow_lay` + - defaults: `revision`(0) `category`("") `defaultdir`(0) `partcolors`({color:[]}) `offerid`(-1) `buyout`(false) `rentofferid`(-1) `rentbuyout`(false) `bc`(false) `excludeddynamic`(false) `customparams`("") `specialtype`(1) `canlayon` as above `furniline`("") `environment`("") `rare`(false) +- [ ] **Step 4:** Commit the locked map: `git commit -am "docs(plan): lock furnidata field map"` + +### Task 2: `FurnidataWriter.create(...)` + unit test (TDD) + +**Files:** Modify `FurnidataWriter.java`; Create `FurnidataWriterCreateTest.java`. + +- [ ] **Step 1: Failing test** — create `FurnidataWriterCreateTest` that: writes a temp single-file furnidata `{ "roomitemtypes": { "furnitype": [ { "id":1, "classname":"old", "name":"Old" } ] }, "wallitemtypes": { "furnitype": [] } }`, calls `writer.create(entryObjectJson5, FurnitureType.FLOOR, /*id*/2, "newcn")`, then reads it back with `FurnidataReader` and asserts BOTH `old` and `newcn` are present, and that the new entry has id 2. + +```java +@Test void createAppendsFloorEntryPreservingExisting() throws Exception { + Path f = Files.createTempFile("furnidata", ".json5"); + Files.writeString(f, "{\n // comment\n \"roomitemtypes\": { \"furnitype\": [ { \"id\": 1, \"classname\": \"old\", \"name\": \"Old\" } ] },\n \"wallitemtypes\": { \"furnitype\": [] }\n}"); + FurnidataWriter w = new FurnidataWriter(f, false, 10_000_000L, 3); + String entry = "{ \"id\": 2, \"classname\": \"newcn\", \"name\": \"New\", \"description\": \"\" }"; + FurnidataWriter.CreateResult r = w.create("newcn", 2, FurnitureType.FLOOR, entry); + assertEquals(FurnidataWriter.CreateResult.CREATED, r); + var entries = new FurnidataReader(f, 10_000_000L).read(); + assertTrue(entries.stream().anyMatch(e -> e.classname().equals("old"))); + assertTrue(entries.stream().anyMatch(e -> e.classname().equals("newcn") && e.id() == 2)); + assertTrue(Files.readString(f).contains("// comment")); // JSON5 comment preserved +} +``` + +- [ ] **Step 2: Run, expect FAIL** (method missing): `cd Emulator && mvn -q -Dtest=FurnidataWriterCreateTest test` → FAIL/compile error. +- [ ] **Step 3: Implement `create()` + `CreateResult`.** Add to `FurnidataWriter`: + - `public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR }` + - `public CreateResult create(String classname, int id, FurnitureType type, String entryObjectJson5)`: + 1. `cn = classname.trim().toLowerCase`. Scan all entries via `FurnidataReader(allFiles).read()`: if any entry has `cn` → return `ALREADY_EXISTS`; if any entry has the same `id` but a different classname → return `ID_COLLISION`. + 2. Resolve target file: single-file → `source`; split-tier → the configured create tier file (passed in via a `Path targetFile` arg OR resolved here from `items.furnidata.create_tier`; the handler passes the resolved tier dir's first file). If none → `NO_TARGET` (or create the file with a shell — see Step 3b). + 3. Section key = `roomitemtypes` (FLOOR) / `wallitemtypes` (WALL). + 4. Read raw; locate `"
"` → its `"furnitype"` → the `[` … `]` array (reuse `matchingClose`/brace helpers, string-aware). Insert the entry object: if array empty → `[ ]`; else insert `, ` before the closing `]` (preserve indentation). If the section/array is absent in the target file, synthesize it (e.g. add `"roomitemtypes": { "furnitype": [ ] }` into the root object). + 5. `backup(target)` + `atomicWrite(target, edited)`; return `CREATED`. Wrap IO in try/catch → `IO_ERROR`. + - Reuse existing `matchingClose`, `lastUnbalancedBrace`, `jsonEscape`, `backup`, `atomicWrite`. +- [ ] **Step 3b:** Add a helper to find the array insertion point: `static int furnitypeArrayClose(String raw, String section)` returning the index of the `]` that closes `
.furnitype`, or -1 if absent. String-aware brace/bracket scan starting from the section key match. +- [ ] **Step 4: Run, expect PASS.** Add a 2nd test for `ALREADY_EXISTS` (create "old") and a 3rd for `ID_COLLISION` (create classname "x" with id 1). `mvn -q -Dtest=FurnidataWriterCreateTest test` → PASS. +- [ ] **Step 5: Commit** `git commit -am "feat(furnidata): FurnidataWriter.create — append new entry (JSON5-preserving)"` + +### Task 3: `FurnidataEntryBuilder` (items_base row → entry JSON5 string) + +**Files:** Create `FurnidataEntryBuilder.java`. + +Mapping table: **(filled by Task 1 Step 3)** + +- [ ] **Step 1:** Implement `static String build(ResultSet itemsBaseRow, String name, String description)` (or take a typed struct) that returns a JSON5 object string with the mapped fields (use `jsonEscape` for strings; booleans/ints inline). Floor vs wall determined by caller; this just emits the object. Keep field order matching existing entries for readability. +- [ ] **Step 2:** Unit test: feed a fake row (or a small struct), assert the output string parses (Gson) and has `id`, `classname`, `name`, `xdim`, `ydim`, `canstandon`. `mvn -q -Dtest=FurnidataEntryBuilderTest test` → PASS. +- [ ] **Step 3: Commit** `git commit -am "feat(furnidata): items_base → furnidata entry builder"` + +### Task 4: Handler upsert — `FurniEditorUpdateFurnidataEvent` + +**Files:** Modify `FurniEditorUpdateFurnidataEvent.java`. + +- [ ] **Step 1:** Before `writer.write(...)` (line ~122), check existence: `boolean exists = provider.getName(classname) != null || furnidataHasClassname(provider, classname)`. (Add a small helper that reads the source via `FurnidataReader` and checks the classname, since `getName` returns null for entries with empty names too.) +- [ ] **Step 2:** If `exists` → keep current `write()` path (audit action `"edit"`). +- [ ] **Step 3:** Else (missing) → resolve the full `items_base` row for `itemId` (extend `classnameForItem` into a `loadItemBaseRow(itemId)` returning sprite_id/type/width/length/flags/public_name + classname). Determine `FurnitureType` from `type`. Build the entry via `FurnidataEntryBuilder.build(row, nameOrPublic, desc)`. Resolve target tier (config `items.furnidata.create_tier`, default `custom`; for single-file the writer ignores it). Call `writer.create(classname, spriteId, type, entryJson5)`. Map `CreateResult` → success/precise error message (`ALREADY_EXISTS`→fall back to edit; `ID_COLLISION`→"id N already used"; etc.). On `CREATED`: same post-steps as edit (`reindexFromSource` + broadcast 10047 + mirror public_name + audit action `"create"`). +- [ ] **Step 4:** Build the jar: `cd Emulator && mvn -q clean package -DskipTests` → BUILD SUCCESS, note the produced `target/Habbo-*.jar`. +- [ ] **Step 5: Commit** `git commit -am "feat(furni-editor): upsert — create furnidata entry when classname missing (10046)"` + +### Task 5: Client — unlock + relabel + re-fetch + +**Files:** Modify `ui/src/components/furni-editor/views/FurniEditorEditView.tsx`. + +- [ ] **Step 1:** Change the `furnidataEditable` memo (line ~240) so a `null` entry no longer hard-locks: when `furniDataEntry === null`, return `true` (editable → will create). Keep the existing classname-mismatch lock for the present-but-mismatched case. +- [ ] **Step 2:** Replace the warning block (lines ~401-405) with an informational note when `furniDataEntry === null`: "No furnidata entry yet — saving will create one." Prefill the name input from `item.publicName` when entry is null and the field is empty. +- [ ] **Step 3:** Relabel the Save button to "Create entry" when `furniDataEntry === null`, else "Save name/desc". +- [ ] **Step 4:** On `FurniEditorResultEvent` success, re-send `FurniEditorDetailComposer(item.id)` so `furniDataEntry` repopulates (verify the success handler already refetches; if not, add it). +- [ ] **Step 5:** `yarn --cwd E:/Users/simol/Desktop/DEV/ui typecheck` → clean. **Commit** on a client branch (NOT mixed with PR #236): `git checkout -b feat/furni-editor-create-missing origin/Dev` first, cherry-pick this file's change, commit `feat(furni-editor): create furnidata entry when missing (upsert Save)`. + +### Task 6: Runtime verification (Chrome handle) + +- [ ] **Step 1:** Restart the emulator with the new jar (the user runs it / `emulatore.bat`). Reload `localhost:5173`. +- [ ] **Step 2:** Open Furni Editor on a furni with NO furnidata entry (the "DB fallback" case). Confirm name/desc now editable + button reads "Create entry". +- [ ] **Step 3:** Type a name, Save. Expect: success result; console shows 10046 sent + 10047 (FurnitureDataReload) broadcast; the furni's name updates live; reopening the editor shows the entry now present (editable normally). +- [ ] **Step 4:** Verify on disk: the new object appears in `Nitro-Files/nitro-assets/gamedata/FurnitureData.json` under the right section, with the mapped fields, and `FurnidataReader` parses the file (no corruption; a `.bak` was made). + +--- + +## Self-review notes +- Spec coverage: upsert trigger (T4/T5), complete entry from items_base (T3), config tier (T4), id=sprite + collision guard (T2/T4), no renderer change (none here), error cases (T2 CreateResult + T4 mapping), tests (T2/T3 unit + T6 runtime). Covered. +- Field map exact column names are locked in Task 1 before any code consumes them (not a placeholder — an explicit investigation task). +- Config key aligned to existing prefix: `items.furnidata.create_tier`. diff --git a/docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md b/docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md new file mode 100644 index 00000000..c575fc10 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md @@ -0,0 +1,151 @@ +# Furni Editor — create furnidata entry if missing (upsert) + +**Date:** 2026-06-13 +**Status:** Approved design → implementation +**Repos:** Arcturus-Morningstar-Extended (emulator, primary), Nitro-V3 (client, minor) + +## Problem + +In the in-client **Furni Editor**, many furni have **no matching entry in the +furnidata** (split-tier `*.json5` files). Today the editor detects this +(`furniDataEntry === null`), shows a "Public Name (DB fallback)" and **locks** +the name/description fields with the warning "this furni has no matching +furnidata entry … so its display name can't be edited here." +`FurnidataWriter.write()` is **edit-only** — it refuses classnames absent from +furnidata. There is no path to **create** the missing entry. + +Goal: let an operator create the missing furnidata entry directly from the +editor, so the furni gets a real, editable name/description. + +## Decisions (from brainstorming) + +1. **Trigger = upsert Save.** When the entry is missing, the name/desc fields + are *unlocked* (name prefilled from the DB Public Name); the existing "Save + name/desc" creates the entry if absent, edits it if present. No separate + button beyond a relabel ("Create entry" when missing). +2. **Completeness = full entry seeded from `items_base`.** The created entry is + a complete furnidata object (structural fields read from the item's DB row), + not a name-only stub. +3. **Target = config key `furnidata.editor.create_tier` (default `custom`).** + Split-tier → that tier file; single-file furnidata → the single file. + +## Approach + +**Reuse the existing `FurniEditorUpdateFurnidata` packet** (outgoing header +`10046`, result `10044`) and make the **server handler upsert**. Rejected +alternative: a dedicated `Create` packet (10050) — unnecessary, because the +create needs **no extra client-supplied fields** (the server reads `items_base` +for the structural fields and takes name/desc from the existing 10046 payload). + +**Net wire impact: none.** No renderer changes, no new packet. Only: +- Emulator: a new `FurnidataWriter.create(...)` + the 10046 handler becomes + upsert + one config key + an `items_base → furnidata` field mapper. +- Client: unlock the name/desc fields when the entry is missing + relabel Save. + +## Emulator changes (Java) + +### 1. `habbohotel/items/FurnidataWriter.create(...)` +New method, mirrors `write()`'s safety (locate target file, **backup + +atomic write**, preserve JSON5 formatting/comments): +- Resolve target file: read config `furnidata.editor.create_tier` (default + `custom`). If split-tier (manifest present) → that tier's file (create the + file with a valid empty-array JSON5 shell if it doesn't exist yet). If + single-file furnidata → the single file. +- Append a complete entry object (see field mapping) to the correct array + (`roomitemtypes` for floor / `wallitemtypes` for wall). +- **Guards:** refuse if the classname already exists anywhere in furnidata + (caller routes to edit instead); refuse if the chosen `id` (sprite id) is + already used by a *different* classname (id collision would break + `roomItem.name.{id}` / `getFloorItemData(typeId)` resolution). +- Return a result enum/boolean (created / already-exists / id-collision / + io-error) so the handler can message the operator precisely. + +### 2. `FurniEditorUpdateFurnidataEvent` (header 10046) → upsert +- Resolve classname + the full `items_base` row from `itemId` (handler already + resolves classname). +- If furnidata **has** the classname → existing edit path (`write()`). +- Else → build the complete entry from `items_base` + submitted name/desc → + `FurnidataWriter.create(...)`. +- After either path (unchanged from edit): `FurnitureTextProvider.reindexFromSource()`, + broadcast `FurnitureDataReloadComposer` (10047), mirror name into + `items_base.public_name`, audit log (action `"create"` vs `"edit"`), respond + `FurniEditorResultComposer` (10044) with success/precise error. +- Permission `ACC_CATALOGFURNI` + 1000ms rate-limit (unchanged). + +### 3. Config key +`furnidata.editor.create_tier` (default `custom`), read where the writer +resolves the target file. + +### 4. `items_base → furnidata` field mapping (helper) +Read the item's DB definition and map to furnidata JSON. Minimum complete set +(exact column/field names verified during implementation against the +`FurnidataReader` schema + `items_base`): +- `id` = item **sprite id** (the visual/type id — MUST match so the furni + resolves its name/data), `classname` = `item_name`, +- `type` = `"s"` (floor) / `"i"` (wall) from the item type, +- `name` = submitted name (fallback: public_name → classname), `description` = + submitted description, +- `xdim`/`ydim` = width/length, `canstandon`/`cansiton`/`canlayon` from the + item's stand/sit/lay flags, plus the standard furnidata defaults for the + remaining fields (`partcolors`, `offerid = -1`, `buyout`, `bc`, + `excludeddynamic`, `customparams`, `specialtype`, `furniline`, + `environment`, `rare`, `revision`, `category`). + +## Client changes (React) — `FurniEditorEditView.tsx` + +- When `furniDataEntry === null`: **unlock** the name/description inputs + (currently gated by the `furnidataEditable` memo), prefill name from + `item.publicName`, description blank. Replace the "can't be edited here" + warning with an informational note: "No furnidata entry yet — saving will + create one in the «custom» tier." Relabel the Save button to "Create entry" + while missing. +- The Save handler is unchanged — it already sends + `FurniEditorUpdateFurnidataComposer(itemId, { name, description })`. +- On `FurniEditorResultEvent` success, re-fetch detail + (`FurniEditorDetailComposer(itemId)`) so `furniDataEntry` populates and the UI + flips to normal edit mode. + +## Data flow + +``` +Save (entry missing) + → 10046 UpdateFurnidata(itemId, {name, desc}) + → handler: classname absent → build complete entry from items_base + name/desc + → FurnidataWriter.create(...) into the custom tier (atomic + backup) + → reindexFromSource() + broadcast 10047 FurnitureDataReload + → every client's catalog/inventory/infostand refreshes; the rendered + furni now resolves its real name + → mirror items_base.public_name + → audit "create" + → 10044 result(success) + → client re-fetches detail → entry now present → normal edit mode +``` + +## Error handling / edge cases + +- Classname already present (lookup race) → routed to edit (upsert). +- Sprite id already used by a different classname → refuse + "id N already + used by classname X". +- `items_base` row missing → refuse + error (shouldn't happen for a known item). +- Tier file absent → created with a valid JSON5 shell. +- Empty submitted name → fall back to public_name, else classname. +- Concurrency: reuse `write()`'s file lock + atomic write + backup. + +## Testing + +- **Emulator unit:** `FurnidataWriter.create` writes a valid JSON5 entry into + the target tier; idempotency guard (already-exists); id-collision guard; + round-trips through `FurnidataReader`. +- **Runtime (Chrome handle available):** in the Furni Editor select a furni + with no furnidata entry (the live "DB fallback" case), type a name, Save → + entry created, furni name updates live (10047 broadcast), reopen → entry + present and editable. Verify the new object lands in the `custom` tier file + and `FurnidataReader` parses it. + +## Out of scope + +- No new wire packet; no renderer changes. +- No bulk/batch creation; one furni at a time via the editor. +- No editing of structural fields from the UI (only name/desc, as today); the + structural fields are seeded once at creation from `items_base`. +- No deletion of furnidata entries (separate concern).