12 KiB
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— addcreate(...)+ aCreateResultenum. - Create:
Emulator/.../habbohotel/items/FurnidataEntryBuilder.java— maps anitems_baserow → 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(defaultcustom) 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_basecolumns:grep -n "items_base" Emulator/.../habbohotel/items/ItemManager.javathen read theItemconstructor that consumesSELECT * 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 whereverFurnitureDataLoader.parseFloorItemsbuilds aFurnitureData). 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; sectionroomitemtypes(floor)/wallitemtypes(wall) ←items_base.type(s/i)name← submitted name (fallbackpublic_name→item_name) ;description← submitted descxdim←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)canlayonas abovefurniline("")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
FurnidataWriterCreateTestthat: writes a temp single-file furnidata{ "roomitemtypes": { "furnitype": [ { "id":1, "classname":"old", "name":"Old" } ] }, "wallitemtypes": { "furnitype": [] } }, callswriter.create(entryObjectJson5, FurnitureType.FLOOR, /*id*/2, "newcn"), then reads it back withFurnidataReaderand asserts BOTHoldandnewcnare present, and that the new entry has id 2.
@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 toFurnidataWriter:public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR }public CreateResult create(String classname, int id, FurnitureType type, String entryObjectJson5):cn = classname.trim().toLowerCase. Scan all entries viaFurnidataReader(allFiles).read(): if any entry hascn→ returnALREADY_EXISTS; if any entry has the sameidbut a different classname → returnID_COLLISION.- Resolve target file: single-file →
source; split-tier → the configured create tier file (passed in via aPath targetFilearg OR resolved here fromitems.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). - Section key =
roomitemtypes(FLOOR) /wallitemtypes(WALL). - Read raw; locate
"<section>"→ its"furnitype"→ the[…]array (reusematchingClose/brace helpers, string-aware). Insert the entry object: if array empty →[ <entry> ]; else insert, <entry>before the closing](preserve indentation). If the section/array is absent in the target file, synthesize it (e.g. add"roomitemtypes": { "furnitype": [ <entry> ] }into the root object). backup(target)+atomicWrite(target, edited); returnCREATED. 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<section>.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 forID_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 (usejsonEscapefor 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 viaFurnidataReaderand checks the classname, sincegetNamereturns null for entries with empty names too.) - Step 2: If
exists→ keep currentwrite()path (audit action"edit"). - Step 3: Else (missing) → resolve the full
items_baserow foritemId(extendclassnameForIteminto aloadItemBaseRow(itemId)returning sprite_id/type/width/length/flags/public_name + classname). DetermineFurnitureTypefromtype. Build the entry viaFurnidataEntryBuilder.build(row, nameOrPublic, desc). Resolve target tier (configitems.furnidata.create_tier, defaultcustom; for single-file the writer ignores it). Callwriter.create(classname, spriteId, type, entryJson5). MapCreateResult→ success/precise error message (ALREADY_EXISTS→fall back to edit;ID_COLLISION→"id N already used"; etc.). OnCREATED: 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 producedtarget/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
furnidataEditablememo (line ~240) so anullentry no longer hard-locks: whenfurniDataEntry === null, returntrue(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 fromitem.publicNamewhen 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
FurniEditorResultEventsuccess, re-sendFurniEditorDetailComposer(item.id)sofurniDataEntryrepopulates (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/Devfirst, cherry-pick this file's change, commitfeat(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). Reloadlocalhost: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.jsonunder the right section, with the mapped fields, andFurnidataReaderparses the file (no corruption; a.bakwas 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.