You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
docs(furni-editor): design spec — create furnidata entry if missing (upsert)
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user