Files
Nitro_Render_V3/docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md
T
simoleo89 842d8407e8 docs(furni): spec — server-authoritative furni names/descriptions from JSON + live
Design for sourcing furni display names from furnidata JSON (DB keeps technical data), with a live delta-broadcast pipeline (emulator file-watch -> renderer patch -> client refresh) and a security hardening section. Cross-repo reference copy.
2026-06-04 20:13:08 +02:00

16 KiB
Raw Blame History

Furni names from JSON (server-authoritative) — Design

  • Date: 2026-06-04
  • Status: Draft for review
  • Scope: Cross-repo — Arcturus (emulator), Nitro_Render_V3 (renderer), Nitro-V3 (client)
  • Out of scope: furni-editor feature/packets, NitroV3-Housekeeping (CMS), server-side multi-language, description rendering in the infostand.

1. Problem & motivation

Today a furni's display name lives in two independent places that drift apart:

  • DBitems_base.public_name (Item.fullName), used by the emulator.
  • furnidata JSON — used by the client (the client already resolves all visible furni names/descriptions from furnidata, keyed by classname).

This forces admins to maintain names twice and causes mismatches. We want one source of truth: the furnidata JSON owns display names & descriptions, the DB owns technical data. Editing furnidata should reflect everywhere — server-pronounced strings and every connected client — live, with no DB edit and no restart.

This is a single, unified refactor whose payoff is admin furni management: one place to edit, consistent everywhere.

2. Source-of-truth contract

Concern Owner Storage Read by
classname (item_name / Item.name) DB items_base.item_name join key → furnidata and .nitro asset; isPet/isBot; wired wf_ fallback
technical data (dimensions, stateCount, flags, interaction, effects) DB items_base.* emulator simulation
display name JSON furnidata (per classname) emulator (getDisplayName) + client (furnidata, unchanged)
description JSON furnidata (per classname) client only (catalog) — no server consumer

Invariants:

  1. The bridge is classname, not a numeric id. Item.name ↔ furnidata classname.
  2. public_name (Item.fullName) is NOT removed: it remains (a) the fallback when a classname is missing from furnidata, and (b) the technical token for wired furni (Item.java:107-116 reads fullName.startsWith("wf_")). No schema migration. No DROP.
  3. There is no description column in items_base; description is JSON-only and has no server consumer → the emulator gets no getDescription().
  4. One furnidata artifact is shared truth: the file the emulator indexes must be the same furnidata the client loads (deploy invariant, §7).
  5. Server emits names in the base locale of the furnidata file. Player-facing multi-language stays a client localization-layer concern (unchanged).

3. Architecture — two independent pieces

The refactor is two pieces that share only the furnidata file and one new packet. They do not depend on each other.

  • Piece 1 — Server-authoritative names. The emulator's pronounced names come from furnidata.
  • Piece 2 — Liveness via delta. When the furnidata file changes, connected clients (and the server index) update without reconnecting, via a minimal delta broadcast.

4. Piece 1 — Emulator (server-authoritative names)

4.1 FurnidataReader (new, package com.eu.habbo.habbohotel.items)

A neutral, shared reader extracted so the editor is not touched. Responsibilities:

  • Resolve the furnidata source reusing the same already-configured path as the editor: furni.editor.renderer.config.pathfurnidata.urlfurni.editor.asset.base.path (see FurniDataManager.resolveSource() for the exact resolution we mirror). Default to those values so admins configure once.
  • Support both layouts the editor already supports: single file (FurnitureData.json) and split-tier directory (core/custom/seasonal, manifest.json5, JSON5 with comments; later tiers override earlier). Reuse the JSON5 strip logic (extract to the shared reader).
  • Parse roomitemtypes (floor) and wallitemtypes (wall) → return a flat list of FurnidataEntry { int id, String classname, FurnitureType type, String name, String description }.

Security requirements on the reader (furnidata is untrusted input):

  • Path-traversal guard. When resolving split-tier manifest entries (tiers[], files[]) via dir.resolve(name), normalize the result and reject any path that escapes the configured base dir (absolute paths, ..). The existing FurniDataManager lacks this guard — the shared reader MUST add it (do not propagate the gap).
  • Size cap. Refuse to load a furnidata file/dir above a configurable max (default e.g. 64 MB) to bound parse cost.
  • Sanitization at the boundary. Every name/description is sanitized on load: truncate to 256 chars, strip control characters and newlines, and neutralize % tokens (so they cannot inject into String.replace placeholder chains, server- or wired-side). Normal text/emoji/non-latin scripts pass through.
  • Fail-safe. Any IO/parse error is caught and logged; the provider keeps the last-good index (or empty on first load) and never throws — boot must not crash on a bad furnidata.

4.2 FurnitureTextProvider (new, package items)

  • Holds volatile Map<String /*classname lowercase*/, FurniText {int id, String name, String description}>.
  • reindex(): read via FurnidataReader → build a new immutable map → compute delta vs the previous map (§5) → atomically swap the reference → return the delta.
  • Initialized in GameEnvironment.load near ItemManager. Resolution is lazy, so boot order is not critical and Item objects do not depend on the provider at load time.
  • Toggle items.furnidata.names.enabled (default true). When false, getDisplayName() returns the DB value (instant rollback, no recompile).

4.3 Item.getDisplayName()

String getDisplayName():
    if !enabled: return fullName
    FurniText t = FurnitureTextProvider.get(this.name /* classname, lowercased */)
    return (t != null && t.name not blank) ? t.name : this.fullName   // never null

No getDescription() on the server (no consumer).

4.4 Swap list (exhaustive — verified)

Replace item.getFullName()item.getDisplayName() at exactly these 6 sites:

Site Context
CatalogBuyItemAsGiftEvent.java:251 LTD daily-total alert (gift)
CatalogBuyItemAsGiftEvent.java:262 LTD daily-item alert (gift)
CatalogManager.java:1057 LTD daily-total alert (buy)
CatalogManager.java:1063 LTD daily-item alert (buy)
WiredTextPlaceholderUtil.java:282 wired %furni.name% (keep existing getName() ultimate fallback)
WatchAndEarnRewardComposer.java:21 appendString(...) — sends name in a packet

Do NOT change (technical, use item_name/classname): PresentItemOpenedComposer:24, GiftCommand:72, SendGift:82, SellItemEvent:37,45, CloseDiceEvent:34, isPet/isBot, and the wired wf_ fallback in Item.load. The catalog offer/page serialization sends no display name (CatalogItem serializes catalog_name + sprite only) — confirmed, nothing to change there.

5. Piece 2 — Liveness via delta

5.1 Server: file watcher + diff + broadcast

  • A WatchService watches the resolved furnidata location on a single, serialized watcher thread (so reindex never races itself). For the split-tier layout, register the base dir and each tier dir. Debounce (~750 ms) to coalesce burst writes, plus a minimum interval between broadcasts (e.g. ≥5 s) to cap amplification.
  • On settle → FurnitureTextProvider.reindex() → diff old vs new by classname:
    • added (new classname) and changed (name or description differs) → included.
    • removed classnames → ignored (rare; resolved on client reconnect).
  • Broadcast decision (anti-DoS):
    • delta empty → no broadcast.
    • delta size ≤ cap (e.g. 500 entries) → broadcast FurnitureDataReload in delta mode.
    • delta size > cap (mass replace) → broadcast in reload-hint mode (compact signal; clients re-load furnidata at next opportunity) instead of a giant per-client payload.
  • The broadcast is triggered only by the file watcher — there is no client-initiated reload path. This is a security property to preserve (clients cannot induce reindex/broadcast).

5.2 Wire contract — new packet FurnitureDataReload

  • Composer (Arcturus): FurnitureDataReloadComposer, new dedicated header id (pick a free id; document on both sides). Two modes:
    int   mode           // 0 = delta, 1 = reload-hint
    // mode == 0 (delta):
    int   count          // bounded by the server cap; the client MUST also bound it on read
    count × {
      string type        // "S" (floor) | "I" (wall)
      int    id           // furnidata numeric id (for localization-key + FurnitureData lookup)
      string classname
      string name         // already sanitized server-side
      string description
    }
    // mode == 1 (reload-hint): no further fields (optionally an int revision for cache-busting)
    
  • Parser/Event (renderer): FurnitureDataReloadEvent + FurnitureDataReloadParser reading the same shape. The parser bounds count (reject/clamp absurd values) and tolerates truncation (bytesAvailable pattern) so a malformed/MITM payload cannot allocate unbounded memory. Registered in SessionDataManager.init() via GetCommunication().registerMessageEvent(new FurnitureDataReloadEvent(...)) (same pattern as the existing FurniDataUpdatedEvent registration, but a distinct handler).

5.3 Renderer: separate patch path (no editor reuse)

  • New method, e.g. SessionDataManager.applyFurnidataDelta(entries)distinct from the editor's applyLiveFurnitureNameUpdate(...) (SessionDataManager.ts:84), which we leave intact.
  • Delta mode (0): for each entry, patch the corresponding FurnitureData (floor/wall, by id) — update _localizedName and _description — and re-register the localization keys roomItem.name/desc.{id} / wallItem.name/desc.{id} (mirrors FurnitureDataLoader:105-110).
  • Reload-hint mode (1): re-run the furnidata load (FurnitureDataLoader, re-fetching furnidata.url with cache-bust) — the appropriate response to a mass change.
  • In both modes, after the batch dispatch the window event once: window.dispatchEvent(new CustomEvent('nitro-localization-updated')).

5.4 Client: zero changes

All three furni surfaces already subscribe to nitro-localization-updated and re-derive:

  • catalog — useCatalog.ts:919
  • inventory — useInventoryFurni.ts:137 (→ refreshGroupItemsLocalization)
  • infostand — useAvatarInfoWidget.ts:425 (→ getFurniInfo, which reads furnitureData.name)

No Nitro-V3 edits are required for Piece 2.

6. Admin-facing outcome

Edit one place — the furnidata JSON — and display names update live across: server-pronounced strings (catalog LTD alerts, wired %furni.name%, Watch&Earn), and every connected client's catalog, inventory, and furni infostand. No DB edit, no restart, no double maintenance.

7. Constraints, risks, invariants

  1. Locale no-clobber. If per-locale furni text override files are in use (they override roomItem.name.{id} after furnidata load), a live delta that re-registers base names would revert overridden ids to base. Mitigation options for the plan: re-apply active overrides after the delta, or skip the localization-key patch for ids with an active override (still patch the FurnitureData object). For single-furnidata setups (typical retro) there is no override and no issue. Document the limitation.
  2. Deploy invariant. furni.editor.asset.base.path/furnidata.url (what the emulator watches) and the furnidata the client loaded must be the same artifact, else the server delta references entries the client doesn't have.
  3. public_name fallback. Wired wf_ items absent from furnidata would show the raw wf_… token as their display name (internal/invisible furni — acceptable).
  4. Split-layout watcher. The watcher must register all tier dirs; missing a tier dir means live updates from that tier are not detected (resolved on reconnect).
  5. Performance. getDisplayName() is a single HashMap lookup on cold paths (catalog alerts, wired text, Watch&Earn) — negligible.

8. Security

With this refactor the furnidata becomes a security-relevant input: its strings now flow into server output (catalog LTD alerts, wired %furni.name%, the Watch&Earn packet) and into a broadcast to every connected client. Regular players cannot influence names (names are admin-owned, keyed by classname); the threat is untrusted furnidata content (third-party furni packs, imports, a compromised editor/supply chain). Controls:

  1. Boundary sanitization (see §4.1): cap 256 chars, strip control/newline, neutralize %. Neutralizing % at load makes every String.replace("%itemname%", name) / %furni.name% site injection-safe; as defense-in-depth, substitute the (untrusted) furni name last in any placeholder chain.
  2. Path-traversal guard in the shared reader (§4.1) — reject manifest paths escaping the base dir. Closes a gap the current editor reader does not cover.
  3. DoS / amplification controls (§5.1): single serialized watcher thread, debounce + minimum broadcast interval, delta-size cap with reload-hint fallback for mass changes, furnidata file-size cap.
  4. Fail-safe loading (§4.1): bad/corrupt furnidata never crashes boot; last-good index is kept; getDisplayName() falls back to public_name.
  5. Robust client parser (§5.2): bound count, tolerate truncation — a malformed/MITM FurnitureDataReload cannot allocate unbounded memory client-side.
  6. No client-triggered reload (§5.1): only the file watcher broadcasts. Do not add any client→server reload request. Preserve this property.
  7. Minimal disclosure: the delta carries only name/description (already public via furnidata) — never other fields from the server-side file.
  8. Concurrency: volatile index reference + atomic swap + single reindex thread → no torn reads.

9. Testing

  • Emulator (JUnit): FurnidataReader parses single-file and split-tier (JSON5, tier override); FurnitureTextProvider lookup by lowercased classname, fallback to public_name when absent, atomic reindex; reindex() diff produces correct added/changed delta and ignores removals; Item.getDisplayName() honors the enable toggle.
  • Renderer (Vitest): FurnitureDataReloadParser reads the payload shape; applyFurnidataDelta patches floor/wall FurnitureData by id, re-registers localization keys, dispatches nitro-localization-updated once.
  • Client (Vitest): existing subscribers (useCatalog, useInventoryFurni, useAvatarInfoWidget) refresh on nitro-localization-updated (regression guard; no new code).
  • Manual acceptance: edit a furni name in furnidata → live update in catalog + inventory + infostand without refresh; a wired %furni.name% sign and a Watch&Earn reward show the new name.
  • Security tests: reader rejects a split-tier manifest with ../ traversal; a name containing %limit%/%user.name% does not inject into catalog alerts or wired text (% neutralized); oversized furnidata is refused; corrupt furnidata keeps last-good index and does not crash; a mass change emits a reload-hint (not a giant delta); the client parser clamps an absurd count.

10. Open questions

  • Free header id for FurnitureDataReload (assign during implementation; document both sides).
  • Whether any retro on this stack actually ships per-locale furni override files (governs whether constraint §7.1 is live or moot).