From fdcd3a73234dd9d77b2b161213319a68fade7914 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:22:52 +0200 Subject: [PATCH 1/5] fix(furnieditor): validate item update payloads --- .../furnieditor/FurniEditorDeleteEvent.java | 4 +- .../furnieditor/FurniEditorDetailEvent.java | 4 +- .../furnieditor/FurniEditorHelper.java | 9 ++ .../furnieditor/FurniEditorUpdateEvent.java | 58 ++------ .../furnieditor/FurniEditorUpdatePayload.java | 133 ++++++++++++++++++ .../FurniEditorUpdatePayloadTest.java | 52 +++++++ 6 files changed, 207 insertions(+), 53 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java 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/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/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,")); + } +} From aec61064aea93adc49cf3183bc32f370239e146d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:08:17 +0200 Subject: [PATCH 2/5] fix(furnidata): prefer renderer config source Resolve furnidata from the renderer config and asset base before falling back to the legacy items.furnidata.path override. This keeps the emulator aligned with the same furnidata URL the UI/renderer already consume. Keep the legacy path as a compatibility fallback for older installs, but stop exposing absolute furnidata file paths in the startup log. The provider now reports a compact manager-style source label instead. Add coverage proving renderer-config furnidata.url wins over the legacy path when both are present. --- .../items/FurnidataSourceResolver.java | 36 +++++++++++-------- .../items/FurnitureTextProvider.java | 10 ++++-- .../furnieditor/FurniDataManagerTest.java | 29 +++++++++++++++ 3 files changed, 57 insertions(+), 18 deletions(-) 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/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/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()); + } } From 93e5ea15aa3710248e22b2a15cec3c6412fab817 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:35:07 +0200 Subject: [PATCH 3/5] =?UTF-8?q?docs(furni-editor):=20implementation=20plan?= =?UTF-8?q?=20=E2=80=94=20create=20furnidata=20entry=20if=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-13-furnidata-create-if-missing.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md 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`. From 2bc4340ec998bf4539a04fcd4649811bd36a5ae0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:59:48 +0200 Subject: [PATCH 4/5] 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). --- .../items/FurnidataEntryBuilder.java | 52 +++++++++++ .../habbohotel/items/FurnidataWriter.java | 92 +++++++++++++++++++ .../FurniEditorUpdateFurnidataEvent.java | 38 +++++++- 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java 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, From 54ef2ee2510aa57fa307ea94f765959e17f7b959 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:30:37 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs(furni-editor):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20create=20furnidata=20entry=20if=20missing=20(upsert?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-13-furnidata-create-if-missing-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md 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).