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.
This commit is contained in:
simoleo89
2026-06-04 20:13:08 +02:00
parent becf654c9e
commit 842d8407e8
@@ -0,0 +1,264 @@
# 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:
- **DB** — `items_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.path``furnidata.url``furni.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).