# 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).