Files
Nitro-V3/docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md
T
simoleo89 05c9d649f5 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:01 +02:00

265 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).