You've already forked Nitro_Render_V3
mirror of
https://github.com/duckietm/Nitro_Render_V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'feat/furni-names-from-json-server'
# Conflicts: # packages/communication/src/NitroMessages.ts
This commit is contained in:
@@ -277,3 +277,24 @@ for the React-side bridge code.
|
||||
- `../Nitro-V3` — React 19 client (consumes this lib via link)
|
||||
- `../Arcturus-Morningstar-Extended` — Java emulator (server side)
|
||||
- `../NitroV3-Housekeeping` — Next.js + Prisma admin CMS
|
||||
|
||||
## Live furnidata updates: `FurnitureDataReload` (incoming header 10047)
|
||||
|
||||
Server-pushed furni name/description changes (pairs with Arcturus'
|
||||
`FurnitureDataReloadComposer`). `SessionDataManager.applyFurnidataDelta` (pure
|
||||
`applyFurnidataDeltaTo` in `packages/session/src/furniture/`) patches
|
||||
`_floorItems`/`_wallItems` by id + the `roomItem/wallItem.name/desc.{id}`
|
||||
localization keys, then dispatches the window event `nitro-localization-updated`
|
||||
so the client's already-subscribed surfaces refresh. `mode` 0 = delta, 1 =
|
||||
reload-hint (re-runs `FurnitureDataLoader.init()`). Kept SEPARATE from the
|
||||
furni-editor's `applyLiveFurnitureNameUpdate`.
|
||||
|
||||
**Adding an incoming packet:** id in `IncomingHeader.ts` -> map in
|
||||
`NitroMessages.ts` (`this._events.set(IncomingHeader.X, XEvent)`) -> Event +
|
||||
Parser under `messages/incoming/<area>` + `messages/parser/<area>` -> wire the
|
||||
barrel chain (`<area>/index.ts` -> parent `index.ts` -> package `src/index.ts`).
|
||||
|
||||
**Gotchas:**
|
||||
- A branch based on `origin/Dev` may NOT contain the furni-editor slice
|
||||
(`FurniDataUpdatedEvent` / `applyLiveFurnitureNameUpdate`) — verify, don't assume.
|
||||
- Building the renderer in a fresh git worktree needs its own `yarn install`.
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
# Furni Names from JSON — Liveness (Piece 2) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When the furnidata file changes, push only the changed names to every connected client so catalog/inventory/infostand update live, without a giant re-download and without a reconnect.
|
||||
|
||||
**Architecture:** The emulator (building on Piece 1) computes a **delta** during reindex, a single-threaded **file watcher** (debounced + throttled) broadcasts a new `FurnitureDataReload` packet (delta, or a compact reload-hint above a cap). The renderer parses it, patches `FurnitureData` + the localization keys, and dispatches the existing `nitro-localization-updated` window event — so the three already-subscribed client surfaces refresh. **No Nitro-V3 client code changes.**
|
||||
|
||||
**Tech Stack:** Java (Arcturus, Gson, java.nio WatchService) + TypeScript (Nitro_Render_V3, Vitest).
|
||||
|
||||
**Depends on:** Piece 1 plan (`FurnitureTextProvider`, `FurnidataReader`, `FurnidataEntry`, `Item.getDisplayName()`) merged on the same branch.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md` (§5, §7, §8).
|
||||
|
||||
**Repos / branch:** `feat/furni-names-from-json-server` in both `Arcturus-Morningstar-Extended` and `Nitro_Render_V3`.
|
||||
|
||||
**Wire contract — packet `FurnitureDataReload` (server → client), header `10047`:**
|
||||
```
|
||||
int mode // 0 = delta, 1 = reload-hint
|
||||
// mode == 0 (delta):
|
||||
int count // server-capped; client clamps on read
|
||||
count × { string type ("S"|"I"); int id; string classname; string name; string description }
|
||||
// mode == 1: no further fields
|
||||
```
|
||||
`10046` is taken (editor `FURNI_DATA_UPDATED`); `10047` is the new id. Task R1 Step 2 verifies it is free.
|
||||
|
||||
---
|
||||
|
||||
## Part A — Renderer (Nitro_Render_V3)
|
||||
|
||||
All paths relative to `Nitro_Render_V3/`. Build/test: `yarn build`, `yarn test`.
|
||||
|
||||
### Task R1: IncomingHeader + parser + event
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/communication/src/messages/incoming/IncomingHeader.ts:498`
|
||||
- Create: `packages/communication/src/messages/parser/furniture/FurnitureDataReloadParser.ts`
|
||||
- Create: `packages/communication/src/messages/incoming/furniture/FurnitureDataReloadEvent.ts`
|
||||
|
||||
- [ ] **Step 1: Add the incoming header constant**
|
||||
|
||||
In `packages/communication/src/messages/incoming/IncomingHeader.ts`, right after line 498 (`public static FURNI_DATA_UPDATED = 10046;`), add:
|
||||
|
||||
```ts
|
||||
public static FURNITURE_DATA_RELOAD = 10047;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the id is free**
|
||||
|
||||
Run: `grep -rn "10047" packages/communication/src/messages/incoming/IncomingHeader.ts packages/communication/src/messages/outgoing/OutgoingHeader.ts`
|
||||
Expected: only the line you just added. If `10047` already exists, use the next free integer and keep it identical to the Arcturus side (Task A1).
|
||||
|
||||
- [ ] **Step 3: Create the parser**
|
||||
|
||||
Create `packages/communication/src/messages/parser/furniture/FurnitureDataReloadParser.ts`:
|
||||
|
||||
```ts
|
||||
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
|
||||
|
||||
export interface FurnidataDeltaEntry
|
||||
{
|
||||
type: string; // "S" floor | "I" wall
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class FurnitureDataReloadParser implements IMessageParser
|
||||
{
|
||||
private static readonly MAX_ENTRIES = 100000;
|
||||
|
||||
private _mode: number;
|
||||
private _entries: FurnidataDeltaEntry[];
|
||||
|
||||
public flush(): boolean
|
||||
{
|
||||
this._mode = 0;
|
||||
this._entries = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
public parse(wrapper: IMessageDataWrapper): boolean
|
||||
{
|
||||
if(!wrapper) return false;
|
||||
|
||||
this._mode = wrapper.readInt();
|
||||
this._entries = [];
|
||||
|
||||
if(this._mode === 0)
|
||||
{
|
||||
let count = wrapper.readInt();
|
||||
if(count < 0) count = 0;
|
||||
if(count > FurnitureDataReloadParser.MAX_ENTRIES) count = FurnitureDataReloadParser.MAX_ENTRIES;
|
||||
|
||||
for(let i = 0; i < count; i++)
|
||||
{
|
||||
this._entries.push({
|
||||
type: wrapper.readString(),
|
||||
id: wrapper.readInt(),
|
||||
classname: wrapper.readString(),
|
||||
name: wrapper.readString(),
|
||||
description: wrapper.readString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public get mode(): number { return this._mode; }
|
||||
public get entries(): FurnidataDeltaEntry[] { return this._entries; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the event**
|
||||
|
||||
Create `packages/communication/src/messages/incoming/furniture/FurnitureDataReloadEvent.ts`:
|
||||
|
||||
```ts
|
||||
import { IMessageEvent } from '@nitrots/api';
|
||||
import { MessageEvent } from '@nitrots/events';
|
||||
import { FurnitureDataReloadParser } from '../../parser/furniture/FurnitureDataReloadParser';
|
||||
|
||||
export class FurnitureDataReloadEvent extends MessageEvent implements IMessageEvent
|
||||
{
|
||||
constructor(callBack: Function)
|
||||
{
|
||||
super(callBack, FurnitureDataReloadParser);
|
||||
}
|
||||
|
||||
public getParser(): FurnitureDataReloadParser
|
||||
{
|
||||
return this.parser as FurnitureDataReloadParser;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Export both from the barrels**
|
||||
|
||||
In `packages/communication/src/messages/parser/furniture/index.ts` add:
|
||||
|
||||
```ts
|
||||
export * from './FurnitureDataReloadParser';
|
||||
```
|
||||
|
||||
In `packages/communication/src/messages/incoming/furniture/index.ts` add:
|
||||
|
||||
```ts
|
||||
export * from './FurnitureDataReloadEvent';
|
||||
```
|
||||
|
||||
(If either `furniture/index.ts` does not exist, create it with the single `export * from './FurnitureDataReloadParser';` / `...Event` line and add `export * from './furniture';` to the parent `incoming/index.ts` and `parser/index.ts`.)
|
||||
|
||||
- [ ] **Step 6: Compile**
|
||||
|
||||
Run: `yarn compile:fast`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/communication/src/messages/incoming/IncomingHeader.ts packages/communication/src/messages/parser/furniture/ packages/communication/src/messages/incoming/furniture/
|
||||
git commit -m "feat(communication): FurnitureDataReload incoming event + parser (header 10047)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R2: Map the header → event in NitroMessages
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/communication/src/NitroMessages.ts:91`
|
||||
|
||||
- [ ] **Step 1: Register the event class**
|
||||
|
||||
In `packages/communication/src/NitroMessages.ts`, right after line 91 (`this._events.set(IncomingHeader.FURNI_DATA_UPDATED, FurniDataUpdatedEvent);`), add:
|
||||
|
||||
```ts
|
||||
this._events.set(IncomingHeader.FURNITURE_DATA_RELOAD, FurnitureDataReloadEvent);
|
||||
```
|
||||
|
||||
Ensure `FurnitureDataReloadEvent` is imported at the top of the file (add it to the existing incoming-events import block, e.g. `import { ..., FurnitureDataReloadEvent } from './messages';` matching how `FurniDataUpdatedEvent` is imported there).
|
||||
|
||||
- [ ] **Step 2: Compile**
|
||||
|
||||
Run: `yarn compile:fast`
|
||||
Expected: no errors (the symbol resolves through the barrels from Task R1 Step 5).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/communication/src/NitroMessages.ts
|
||||
git commit -m "feat(communication): route FURNITURE_DATA_RELOAD to its event"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R3: `applyFurnidataDelta` + reload-hint + register handler (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/session/src/SessionDataManager.ts`
|
||||
- Test: `packages/session/src/__tests__/SessionDataManager.furnidataDelta.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/session/src/__tests__/SessionDataManager.furnidataDelta.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Minimal localization + window doubles
|
||||
const setValue = vi.fn();
|
||||
vi.mock('@nitrots/localization', () => ({
|
||||
GetLocalizationManager: () => ({ setValue })
|
||||
}));
|
||||
vi.mock('@nitrots/events', async (orig) => {
|
||||
const actual = await orig() as any;
|
||||
return { ...actual, GetEventDispatcher: () => ({ dispatchEvent: vi.fn() }) };
|
||||
});
|
||||
|
||||
import { applyFurnidataDeltaTo } from '../furniture/applyFurnidataDelta';
|
||||
|
||||
describe('applyFurnidataDelta', () => {
|
||||
beforeEach(() => { setValue.mockClear(); });
|
||||
|
||||
it('patches floor FurnitureData name/description and localization keys, dispatches window event', () => {
|
||||
const floor: any = { _localizedName: 'Old', _description: 'Old desc' };
|
||||
const floorItems = new Map<number, any>([[ 5, floor ]]);
|
||||
const wallItems = new Map<number, any>();
|
||||
const dispatched: string[] = [];
|
||||
const win: any = { dispatchEvent: (e: any) => dispatched.push(e.type) };
|
||||
|
||||
applyFurnidataDeltaTo(
|
||||
[ { type: 'S', id: 5, classname: 'chair', name: 'New', description: 'New desc' } ],
|
||||
floorItems, wallItems, { setValue } as any, win
|
||||
);
|
||||
|
||||
expect(floor._localizedName).toBe('New');
|
||||
expect(floor._description).toBe('New desc');
|
||||
expect(setValue).toHaveBeenCalledWith('roomItem.name.5', 'New');
|
||||
expect(setValue).toHaveBeenCalledWith('roomItem.desc.5', 'New desc');
|
||||
expect(dispatched).toContain('nitro-localization-updated');
|
||||
});
|
||||
|
||||
it('patches wall items by id', () => {
|
||||
const wall: any = { _localizedName: 'W', _description: '' };
|
||||
const wallItems = new Map<number, any>([[ 9, wall ]]);
|
||||
applyFurnidataDeltaTo(
|
||||
[ { type: 'I', id: 9, classname: 'poster', name: 'WallNew', description: 'd' } ],
|
||||
new Map(), wallItems, { setValue } as any, { dispatchEvent: () => {} } as any
|
||||
);
|
||||
expect(wall._localizedName).toBe('WallNew');
|
||||
expect(setValue).toHaveBeenCalledWith('wallItem.name.9', 'WallNew');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `yarn test SessionDataManager.furnidataDelta`
|
||||
Expected: FAIL — `../furniture/applyFurnidataDelta` does not exist.
|
||||
|
||||
- [ ] **Step 3: Extract the pure patch function**
|
||||
|
||||
Create `packages/session/src/furniture/applyFurnidataDelta.ts`:
|
||||
|
||||
```ts
|
||||
import { ILocalizationManager } from '@nitrots/api';
|
||||
import { FurnidataDeltaEntry } from '@nitrots/communication';
|
||||
|
||||
/**
|
||||
* Pure, testable furnidata-delta patcher. Mutates the FurnitureData objects in
|
||||
* the given maps (by id) and the localization keys, then dispatches the
|
||||
* `nitro-localization-updated` window event so subscribed React surfaces refresh.
|
||||
*/
|
||||
export function applyFurnidataDeltaTo(
|
||||
entries: FurnidataDeltaEntry[],
|
||||
floorItems: Map<number, any>,
|
||||
wallItems: Map<number, any>,
|
||||
localization: Pick<ILocalizationManager, 'setValue'>,
|
||||
win: { dispatchEvent: (event: Event) => void }
|
||||
): void
|
||||
{
|
||||
if(!entries || !entries.length) return;
|
||||
|
||||
for(const e of entries)
|
||||
{
|
||||
if(e.type === 'I')
|
||||
{
|
||||
const wall = wallItems.get(e.id);
|
||||
if(wall) { wall._localizedName = e.name; wall._description = e.description; }
|
||||
localization.setValue('wallItem.name.' + e.id, e.name);
|
||||
localization.setValue('wallItem.desc.' + e.id, e.description);
|
||||
}
|
||||
else
|
||||
{
|
||||
const floor = floorItems.get(e.id);
|
||||
if(floor) { floor._localizedName = e.name; floor._description = e.description; }
|
||||
localization.setValue('roomItem.name.' + e.id, e.name);
|
||||
localization.setValue('roomItem.desc.' + e.id, e.description);
|
||||
}
|
||||
}
|
||||
|
||||
if(win && typeof win.dispatchEvent === 'function')
|
||||
{
|
||||
win.dispatchEvent(new CustomEvent('nitro-localization-updated'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `FurnidataDeltaEntry` is not yet re-exported from `@nitrots/communication`, add `export * from './messages/parser/furniture/FurnitureDataReloadParser';` to `packages/communication/src/index.ts` (or the nearest public barrel) so the type is importable.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `yarn test SessionDataManager.furnidataDelta`
|
||||
Expected: PASS — 2 tests.
|
||||
|
||||
- [ ] **Step 5: Wire the methods + handler into SessionDataManager**
|
||||
|
||||
In `packages/session/src/SessionDataManager.ts`, add the import near the other furniture imports:
|
||||
|
||||
```ts
|
||||
import { applyFurnidataDeltaTo } from './furniture/applyFurnidataDelta';
|
||||
import { FurnidataDeltaEntry, FurnitureDataReloadEvent } from '@nitrots/communication';
|
||||
```
|
||||
|
||||
Add these methods just after `applyLiveFurnitureNameUpdate` (after line 115):
|
||||
|
||||
```ts
|
||||
public applyFurnidataDelta(entries: FurnidataDeltaEntry[]): void
|
||||
{
|
||||
applyFurnidataDeltaTo(entries, this._floorItems as any, this._wallItems as any, GetLocalizationManager(), (typeof window !== 'undefined') ? window : { dispatchEvent: () => {} } as any);
|
||||
}
|
||||
|
||||
public async applyFurnidataReloadHint(): Promise<void>
|
||||
{
|
||||
await this._furnitureData.init();
|
||||
if(typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('nitro-localization-updated'));
|
||||
}
|
||||
```
|
||||
|
||||
In `init()`, add a registration to the `this._messageEvents.push(...)` list (after the `FurniDataUpdatedEvent` registration at line 208-212):
|
||||
|
||||
```ts
|
||||
GetCommunication().registerMessageEvent(new FurnitureDataReloadEvent((event: FurnitureDataReloadEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.mode === 1) { void this.applyFurnidataReloadHint(); }
|
||||
else { this.applyFurnidataDelta(parser.entries); }
|
||||
}))
|
||||
```
|
||||
|
||||
(Add a comma after the previous entry as needed so the push args stay comma-separated.)
|
||||
|
||||
- [ ] **Step 6: Compile + run the full session test suite**
|
||||
|
||||
Run: `yarn compile:fast && yarn test packages/session`
|
||||
Expected: no compile errors; all session tests pass including the new ones.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/session/src/furniture/applyFurnidataDelta.ts packages/session/src/SessionDataManager.ts packages/session/src/__tests__/SessionDataManager.furnidataDelta.test.ts packages/communication/src/index.ts
|
||||
git commit -m "feat(session): apply FurnitureDataReload delta + reload-hint, separate from editor path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part B — Emulator (Arcturus-Morningstar-Extended)
|
||||
|
||||
All paths relative to `Arcturus-Morningstar-Extended/Emulator/`.
|
||||
|
||||
### Task A1: Outgoing header + `FurnitureDataReloadComposer`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java`
|
||||
- Create: `src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java`
|
||||
|
||||
- [ ] **Step 1: Add the Outgoing header constant**
|
||||
|
||||
In `src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java`, add (matching the file's existing `public static final int Name = id;` style):
|
||||
|
||||
```java
|
||||
public static final int FurnitureDataReloadComposer = 10047;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the id is free on the server side**
|
||||
|
||||
Run: `grep -rn "= 10047" src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java`
|
||||
Expected: only the line just added. If taken, pick the next free id and keep it equal to the renderer `IncomingHeader.FURNITURE_DATA_RELOAD` (Task R1).
|
||||
|
||||
- [ ] **Step 3: Create the composer**
|
||||
|
||||
Create `src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java`:
|
||||
|
||||
```java
|
||||
package com.eu.habbo.messages.outgoing.furniture;
|
||||
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureType;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class FurnitureDataReloadComposer extends MessageComposer {
|
||||
|
||||
public static final int MODE_DELTA = 0;
|
||||
public static final int MODE_RELOAD_HINT = 1;
|
||||
|
||||
private final int mode;
|
||||
private final List<FurnidataEntry> entries;
|
||||
|
||||
public FurnitureDataReloadComposer(int mode, List<FurnidataEntry> entries) {
|
||||
this.mode = mode;
|
||||
this.entries = entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.FurnitureDataReloadComposer);
|
||||
this.response.appendInt(this.mode);
|
||||
|
||||
if (this.mode == MODE_DELTA) {
|
||||
this.response.appendInt(this.entries.size());
|
||||
for (FurnidataEntry e : this.entries) {
|
||||
this.response.appendString(e.type() == FurnitureType.FLOOR ? "S" : "I");
|
||||
this.response.appendInt(e.id());
|
||||
this.response.appendString(e.classname());
|
||||
this.response.appendString(e.name());
|
||||
this.response.appendString(e.description());
|
||||
}
|
||||
}
|
||||
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Compile**
|
||||
|
||||
Run: `mvn -q compile`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java
|
||||
git commit -m "feat(items): FurnitureDataReloadComposer (header 10047, delta + reload-hint)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A2: `FurnitureTextProvider.reindex` returns a sanitized delta (TDD)
|
||||
|
||||
Piece 1 defined `reindex(List)` returning `void`. Change it to return the changed entries (sanitized), so the watcher can broadcast them. Existing call sites ignore the return value — no other change needed.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java`
|
||||
- Test: `src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java`:
|
||||
|
||||
```java
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnitureTextProviderDeltaTest {
|
||||
|
||||
@Test
|
||||
void firstReindexReturnsAllAsDelta() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||
assertEquals(1, delta.size());
|
||||
assertEquals("Chair", delta.get(0).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedReindexReturnsEmptyDelta() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
List<FurnidataEntry> first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"));
|
||||
p.reindex(first);
|
||||
List<FurnidataEntry> delta = p.reindex(first);
|
||||
assertTrue(delta.isEmpty(), "no change => empty delta");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedNameAppearsInDeltaWithSanitizedValue() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
p.reindex(List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "New %x%", "Sit")));
|
||||
assertEquals(1, delta.size());
|
||||
assertFalse(delta.get(0).name().contains("%"), "delta carries the sanitized name");
|
||||
assertEquals(1, delta.get(0).id());
|
||||
assertEquals(FurnitureType.FLOOR, delta.get(0).type());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `mvn -q test -Dtest=FurnitureTextProviderDeltaTest`
|
||||
Expected: FAIL — `reindex` returns `void` (compile error) / method shape mismatch.
|
||||
|
||||
- [ ] **Step 3: Change `reindex` to compute and return the delta**
|
||||
|
||||
In `FurnitureTextProvider.java`, replace the existing `reindex` method with:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Build a fresh sanitized index, swap it in atomically, and return the
|
||||
* changed/added entries (sanitized) as the delta versus the previous index.
|
||||
*/
|
||||
public java.util.List<FurnidataEntry> reindex(java.util.List<FurnidataEntry> entries) {
|
||||
Map<String, FurniText> next = new HashMap<>(Math.max(16, entries.size() * 2));
|
||||
for (FurnidataEntry e : entries) {
|
||||
String key = baseKey(e.classname());
|
||||
if (key == null) continue;
|
||||
next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description())));
|
||||
}
|
||||
|
||||
Map<String, FurniText> prev = this.index;
|
||||
java.util.List<FurnidataEntry> delta = new java.util.ArrayList<>();
|
||||
for (Map.Entry<String, FurniText> en : next.entrySet()) {
|
||||
FurniText cur = en.getValue();
|
||||
FurniText old = prev.get(en.getKey());
|
||||
if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) {
|
||||
delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description()));
|
||||
}
|
||||
}
|
||||
|
||||
this.index = next; // atomic reference swap
|
||||
return delta;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run both provider tests**
|
||||
|
||||
Run: `mvn -q test -Dtest=FurnitureTextProviderTest,FurnitureTextProviderDeltaTest`
|
||||
Expected: PASS — Piece-1 provider tests (6) still pass (they ignore the return value), delta tests (3) pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java
|
||||
git commit -m "feat(items): reindex returns sanitized furnidata delta"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A3: File watcher — debounce, throttle, cap→hint, broadcast
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java`
|
||||
- Modify: `src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java` (expose source path + start watcher)
|
||||
|
||||
- [ ] **Step 1: Expose the resolved source and start the watcher from `init()`**
|
||||
|
||||
In `FurnitureTextProvider.java`, store the resolved source and start the watcher at the end of `init()`. Replace the body of `init()` with:
|
||||
|
||||
```java
|
||||
private volatile Path source;
|
||||
private FurnidataWatcher watcher;
|
||||
|
||||
public void init() {
|
||||
try {
|
||||
this.source = resolveSource();
|
||||
if (this.source == null) {
|
||||
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name");
|
||||
return;
|
||||
}
|
||||
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||
LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source);
|
||||
|
||||
if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) {
|
||||
this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES);
|
||||
this.watcher.start();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getSource() {
|
||||
return this.source;
|
||||
}
|
||||
```
|
||||
|
||||
(Add `import java.nio.file.Path;` if not already present from Piece 1 Task 4.)
|
||||
|
||||
- [ ] **Step 2: Create the watcher**
|
||||
|
||||
Create `src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java`:
|
||||
|
||||
```java
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardWatchEventKinds;
|
||||
import java.nio.file.WatchKey;
|
||||
import java.nio.file.WatchService;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Watches the furnidata source on a single daemon thread. On change (debounced),
|
||||
* re-indexes via the provider and broadcasts only the delta — or a compact
|
||||
* reload-hint when the delta exceeds the cap. A minimum interval throttles bursts.
|
||||
* Never throws out of the loop.
|
||||
*/
|
||||
public class FurnidataWatcher {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataWatcher.class);
|
||||
|
||||
private final FurnitureTextProvider provider;
|
||||
private final Path watchDir;
|
||||
private final long maxBytes;
|
||||
private final long debounceMs;
|
||||
private final long minIntervalMs;
|
||||
private final int deltaCap;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private long lastBroadcast = 0L;
|
||||
|
||||
public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) {
|
||||
this.provider = provider;
|
||||
// Watch the parent dir for a file, or the dir itself for a split layout.
|
||||
this.watchDir = java.nio.file.Files.isDirectory(source) ? source : source.getParent();
|
||||
this.maxBytes = maxBytes;
|
||||
this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750"));
|
||||
this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000"));
|
||||
this.deltaCap = Integer.parseInt(Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (this.running || this.watchDir == null) return;
|
||||
this.running = true;
|
||||
Thread t = new Thread(this::run, "FurnidataWatcher");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try (WatchService ws = FileSystems.getDefault().newWatchService()) {
|
||||
this.watchDir.register(ws, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||
|
||||
while (this.running) {
|
||||
WatchKey key = ws.take(); // blocks
|
||||
key.pollEvents(); // drain
|
||||
Thread.sleep(this.debounceMs); // debounce burst writes
|
||||
key.pollEvents(); // drain anything that arrived during debounce
|
||||
key.reset();
|
||||
|
||||
try {
|
||||
onChange();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataWatcher: onChange failed", e);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataWatcher stopped", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void onChange() {
|
||||
Path source = this.provider.getSource();
|
||||
if (source == null) return;
|
||||
|
||||
List<FurnidataEntry> delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
|
||||
if (delta.isEmpty()) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - this.lastBroadcast < this.minIntervalMs) {
|
||||
LOGGER.info("FurnidataWatcher: {} changes throttled (min interval)", delta.size());
|
||||
return; // next change will pick up the new baseline
|
||||
}
|
||||
this.lastBroadcast = now;
|
||||
|
||||
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
|
||||
broadcast(composer);
|
||||
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
|
||||
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
|
||||
}
|
||||
|
||||
private void broadcast(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`System.currentTimeMillis()` is fine here (production server runtime). Note: when throttled, the index is still updated but the delta is **not** sent, and it is not re-sent later — those names update on client reconnect (acceptable for infrequent admin edits). To never drop, raise `items.furnidata.delta.cap` is unrelated; instead lower `items.furnidata.watch.min.interval.ms`.
|
||||
|
||||
- [ ] **Step 3: Compile**
|
||||
|
||||
Run: `mvn -q compile`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java
|
||||
git commit -m "feat(items): furnidata file watcher — debounce, throttle, delta cap to reload-hint, broadcast"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part C — Integration & acceptance
|
||||
|
||||
### Task C1: Build both repos + manual live test
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Renderer build + tests**
|
||||
|
||||
Run (in `Nitro_Render_V3/`): `yarn compile:fast && yarn test`
|
||||
Expected: no compile errors; all tests pass including `SessionDataManager.furnidataDelta`.
|
||||
|
||||
- [ ] **Step 2: Emulator build + tests**
|
||||
|
||||
Run (in `Arcturus-Morningstar-Extended/Emulator/`): `mvn -q clean package`
|
||||
Expected: BUILD SUCCESS; all `FurnitureTextProvider*Test` + `FurnidataReaderTest` pass.
|
||||
|
||||
- [ ] **Step 3: Manual live acceptance (running hotel + client)**
|
||||
|
||||
1. Start MariaDB, the emulator (with Piece 1 + Piece 2 jar), and the client; enter a room containing a furni whose furnidata `name` you will change.
|
||||
2. Edit that furni's `name` in the furnidata file and save.
|
||||
3. Within the debounce window (~1s), confirm WITHOUT refreshing the client:
|
||||
- the furni infostand shows the new name (open the furni info),
|
||||
- the catalog offer for it shows the new name,
|
||||
- the inventory tile label shows the new name.
|
||||
4. Check the emulator log: `FurnidataWatcher: broadcast delta (1 entries)`.
|
||||
5. Replace the whole furnidata (mass change) → log shows `broadcast reload-hint`; the client re-loads furnidata and names update.
|
||||
6. Set `items.furnidata.watch.enabled=false`, restart → editing the file no longer pushes; clients update only on reconnect (watcher disabled).
|
||||
|
||||
- [ ] **Step 4: Final no-op commit (optional config docs)**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "docs(items): document items.furnidata.watch.* config keys"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Header id must match** on both sides: `IncomingHeader.FURNITURE_DATA_RELOAD` (renderer) == `Outgoing.FurnitureDataReloadComposer` (Arcturus) == `10047`. Verify both Step-2 grep checks before wiring.
|
||||
- **Do not reuse or modify** the editor path: `FurniDataUpdatedEvent`/`FurniDataUpdatedParser`/`applyLiveFurnitureNameUpdate` stay untouched; the new path is parallel (per the spec decision "keep separate").
|
||||
- **No Nitro-V3 client changes.** The three surfaces (`useCatalog.ts:919`, `useInventoryFurni.ts:137`, `useAvatarInfoWidget.ts:425`) already subscribe to `nitro-localization-updated`. If a regression test is wanted, add it under Nitro-V3 separately; it is not required for this plan.
|
||||
- **Locale no-clobber (spec §7.1):** re-registering `roomItem.name.{id}` from the base furnidata will override an active per-locale text override for that id. If this hotel ships per-locale furni override files, follow up by re-applying overrides after the delta (out of scope here; single-furnidata hotels are unaffected).
|
||||
- **Cache-busting** for the reload-hint: `applyFurnidataReloadHint` re-runs `FurnitureDataLoader.init()` against the configured `furnidata.url`; if the asset host serves a cached copy, add a `?v=<timestamp>` buster to the loader fetch (optional follow-up).
|
||||
@@ -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).
|
||||
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@ export * from './messages/incoming/crafting';
|
||||
export * from './messages/incoming/desktop';
|
||||
export * from './messages/incoming/friendlist';
|
||||
export * from './messages/incoming/furnieditor';
|
||||
export * from './messages/incoming/furniture';
|
||||
export * from './messages/incoming/game';
|
||||
export * from './messages/incoming/game/directory';
|
||||
export * from './messages/incoming/game/lobby';
|
||||
@@ -181,6 +182,7 @@ export * from './messages/parser/crafting';
|
||||
export * from './messages/parser/desktop';
|
||||
export * from './messages/parser/friendlist';
|
||||
export * from './messages/parser/furnieditor';
|
||||
export * from './messages/parser/furniture';
|
||||
export * from './messages/parser/game';
|
||||
export * from './messages/parser/game/directory';
|
||||
export * from './messages/parser/game/lobby';
|
||||
|
||||
@@ -493,6 +493,7 @@ export class IncomingHeader
|
||||
public static FURNI_EDITOR_DETAIL_RESULT = 10041;
|
||||
public static FURNI_EDITOR_INTERACTIONS_RESULT = 10043;
|
||||
public static FURNI_EDITOR_RESULT = 10044;
|
||||
public static FURNITURE_DATA_RELOAD = 10047;
|
||||
|
||||
// Catalog Admin
|
||||
public static CATALOG_ADMIN_RESULT = 10059;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IMessageEvent } from '@nitrots/api';
|
||||
import { MessageEvent } from '@nitrots/events';
|
||||
import { FurnitureDataReloadParser } from '../../parser/furniture/FurnitureDataReloadParser';
|
||||
|
||||
export class FurnitureDataReloadEvent extends MessageEvent implements IMessageEvent
|
||||
{
|
||||
constructor(callBack: Function)
|
||||
{
|
||||
super(callBack, FurnitureDataReloadParser);
|
||||
}
|
||||
|
||||
public getParser(): FurnitureDataReloadParser
|
||||
{
|
||||
return this.parser as FurnitureDataReloadParser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './FurnitureDataReloadEvent';
|
||||
@@ -14,6 +14,7 @@ export * from './crafting';
|
||||
export * from './desktop';
|
||||
export * from './friendlist';
|
||||
export * from './furnieditor';
|
||||
export * from './furniture';
|
||||
export * from './game';
|
||||
export * from './game/directory';
|
||||
export * from './game/lobby';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
|
||||
|
||||
export interface FurnidataDeltaEntry
|
||||
{
|
||||
type: string; // "S" floor | "I" wall
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class FurnitureDataReloadParser implements IMessageParser
|
||||
{
|
||||
private static readonly MAX_ENTRIES = 100000;
|
||||
|
||||
private _mode: number;
|
||||
private _entries: FurnidataDeltaEntry[];
|
||||
|
||||
public flush(): boolean
|
||||
{
|
||||
this._mode = 0;
|
||||
this._entries = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
public parse(wrapper: IMessageDataWrapper): boolean
|
||||
{
|
||||
if(!wrapper) return false;
|
||||
|
||||
this._mode = wrapper.readInt();
|
||||
this._entries = [];
|
||||
|
||||
if(this._mode === 0)
|
||||
{
|
||||
let count = wrapper.readInt();
|
||||
if(count < 0) count = 0;
|
||||
if(count > FurnitureDataReloadParser.MAX_ENTRIES) count = FurnitureDataReloadParser.MAX_ENTRIES;
|
||||
|
||||
for(let i = 0; i < count; i++)
|
||||
{
|
||||
this._entries.push({
|
||||
type: wrapper.readString(),
|
||||
id: wrapper.readInt(),
|
||||
classname: wrapper.readString(),
|
||||
name: wrapper.readString(),
|
||||
description: wrapper.readString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public get mode(): number { return this._mode; }
|
||||
public get entries(): FurnidataDeltaEntry[] { return this._entries; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './FurnitureDataReloadParser';
|
||||
@@ -13,6 +13,7 @@ export * from './crafting';
|
||||
export * from './desktop';
|
||||
export * from './friendlist';
|
||||
export * from './furnieditor';
|
||||
export * from './furniture';
|
||||
export * from './game';
|
||||
export * from './game/directory';
|
||||
export * from './game/lobby';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, IUserDataSnapshot, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api';
|
||||
import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication';
|
||||
import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, FurnitureDataReloadEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication';
|
||||
import type { FurnidataDeltaEntry } from '@nitrots/communication';
|
||||
import { applyFurnidataDeltaTo } from './furniture/applyFurnidataDelta';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { GetLocalizationManager } from '@nitrots/localization';
|
||||
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroEvent, NitroEventType, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
|
||||
@@ -171,7 +173,13 @@ export class SessionDataManager implements ISessionDataManager
|
||||
GetCommunication().registerMessageEvent(new MysteryBoxKeysEvent(this.onMysteryBoxKeysEvent.bind(this))),
|
||||
GetCommunication().registerMessageEvent(new NoobnessLevelMessageEvent(this.onNoobnessLevelMessageEvent.bind(this))),
|
||||
GetCommunication().registerMessageEvent(new AccountSafetyLockStatusChangeMessageEvent(this.onAccountSafetyLockStatusChangeMessageEvent.bind(this))),
|
||||
GetCommunication().registerMessageEvent(new EmailStatusResultEvent(this.onEmailStatus.bind(this)))
|
||||
GetCommunication().registerMessageEvent(new EmailStatusResultEvent(this.onEmailStatus.bind(this))),
|
||||
GetCommunication().registerMessageEvent(new FurnitureDataReloadEvent((event: FurnitureDataReloadEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.mode === 1) { void this.applyFurnidataReloadHint(); }
|
||||
else { this.applyFurnidataDelta(parser.entries); }
|
||||
}))
|
||||
);
|
||||
|
||||
// Store event dispatcher callback for cleanup
|
||||
@@ -564,6 +572,17 @@ export class SessionDataManager implements ISessionDataManager
|
||||
}
|
||||
}
|
||||
|
||||
public applyFurnidataDelta(entries: FurnidataDeltaEntry[]): void
|
||||
{
|
||||
applyFurnidataDeltaTo(entries, this._floorItems as any, this._wallItems as any, GetLocalizationManager(), (typeof window !== 'undefined') ? window : { dispatchEvent: () => {} } as any);
|
||||
}
|
||||
|
||||
public async applyFurnidataReloadHint(): Promise<void>
|
||||
{
|
||||
await this._furnitureData.init();
|
||||
if(typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('nitro-localization-updated'));
|
||||
}
|
||||
|
||||
public getBadgeUrl(name: string): string
|
||||
{
|
||||
return this._badgeImageManager.getBadgeUrl(name);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { applyFurnidataDeltaTo } from '../furniture/applyFurnidataDelta';
|
||||
|
||||
describe('applyFurnidataDeltaTo', () => {
|
||||
const setValue = vi.fn();
|
||||
beforeEach(() => setValue.mockClear());
|
||||
|
||||
it('patches floor FurnitureData name/desc + localization keys, dispatches window event', () => {
|
||||
const floor: any = { _localizedName: 'Old', _description: 'Old desc' };
|
||||
const floorItems = new Map<number, any>([[ 5, floor ]]);
|
||||
const dispatched: string[] = [];
|
||||
const win: any = { dispatchEvent: (e: any) => dispatched.push(e.type) };
|
||||
|
||||
applyFurnidataDeltaTo(
|
||||
[ { type: 'S', id: 5, classname: 'chair', name: 'New', description: 'New desc' } ],
|
||||
floorItems, new Map(), { setValue }, win
|
||||
);
|
||||
|
||||
expect(floor._localizedName).toBe('New');
|
||||
expect(floor._description).toBe('New desc');
|
||||
expect(setValue).toHaveBeenCalledWith('roomItem.name.5', 'New');
|
||||
expect(setValue).toHaveBeenCalledWith('roomItem.desc.5', 'New desc');
|
||||
expect(dispatched).toContain('nitro-localization-updated');
|
||||
});
|
||||
|
||||
it('patches wall items by id', () => {
|
||||
const wall: any = { _localizedName: 'W', _description: '' };
|
||||
const wallItems = new Map<number, any>([[ 9, wall ]]);
|
||||
applyFurnidataDeltaTo(
|
||||
[ { type: 'I', id: 9, classname: 'poster', name: 'WallNew', description: 'd' } ],
|
||||
new Map(), wallItems, { setValue }, { dispatchEvent: () => {} }
|
||||
);
|
||||
expect(wall._localizedName).toBe('WallNew');
|
||||
expect(setValue).toHaveBeenCalledWith('wallItem.name.9', 'WallNew');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FurnidataDeltaEntry } from '@nitrots/communication';
|
||||
|
||||
/**
|
||||
* Pure, testable furnidata-delta patcher. Mutates the FurnitureData objects in
|
||||
* the given maps (by id) and the localization keys, then dispatches the
|
||||
* `nitro-localization-updated` window event so subscribed React surfaces refresh.
|
||||
*/
|
||||
export function applyFurnidataDeltaTo(
|
||||
entries: FurnidataDeltaEntry[],
|
||||
floorItems: Map<number, any>,
|
||||
wallItems: Map<number, any>,
|
||||
localization: { setValue: (key: string, value: string) => void },
|
||||
win: { dispatchEvent: (event: any) => void }
|
||||
): void
|
||||
{
|
||||
if(!entries || !entries.length) return;
|
||||
|
||||
for(const e of entries)
|
||||
{
|
||||
if(e.type === 'I')
|
||||
{
|
||||
const wall = wallItems.get(e.id);
|
||||
if(wall) { wall._localizedName = e.name; wall._description = e.description; }
|
||||
localization.setValue('wallItem.name.' + e.id, e.name);
|
||||
localization.setValue('wallItem.desc.' + e.id, e.description);
|
||||
}
|
||||
else
|
||||
{
|
||||
const floor = floorItems.get(e.id);
|
||||
if(floor) { floor._localizedName = e.name; floor._description = e.description; }
|
||||
localization.setValue('roomItem.name.' + e.id, e.name);
|
||||
localization.setValue('roomItem.desc.' + e.id, e.description);
|
||||
}
|
||||
}
|
||||
|
||||
if(win && typeof win.dispatchEvent === 'function')
|
||||
{
|
||||
const evt = (typeof CustomEvent !== 'undefined')
|
||||
? new CustomEvent('nitro-localization-updated')
|
||||
: { type: 'nitro-localization-updated' } as any;
|
||||
win.dispatchEvent(evt);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user