7.5 KiB
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)
- 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).
- 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. - Target = config key
furnidata.editor.create_tier(defaultcustom). 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 + anitems_base → furnidatafield 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(defaultcustom). 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
(
roomitemtypesfor floor /wallitemtypesfor 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 breakroomItem.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_baserow fromitemId(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(), broadcastFurnitureDataReloadComposer(10047), mirror name intoitems_base.public_name, audit log (action"create"vs"edit"), respondFurniEditorResultComposer(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/canlayonfrom 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 thefurnidataEditablememo), prefill name fromitem.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
FurniEditorResultEventsuccess, re-fetch detail (FurniEditorDetailComposer(itemId)) sofurniDataEntrypopulates 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_baserow 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.createwrites a valid JSON5 entry into the target tier; idempotency guard (already-exists); id-collision guard; round-trips throughFurnidataReader. - 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
customtier file andFurnidataReaderparses 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).