From 54ef2ee2510aa57fa307ea94f765959e17f7b959 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:30:37 +0200 Subject: [PATCH] =?UTF-8?q?docs(furni-editor):=20design=20spec=20=E2=80=94?= =?UTF-8?q?=20create=20furnidata=20entry=20if=20missing=20(upsert)?= 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).