From c0f6504d9c9de241d9821525be8b451991efaa3f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:36:15 +0200 Subject: [PATCH] docs(furni): liveness implementation plan (Piece 2) --- ...4-furni-names-from-json-server-liveness.md | 779 ++++++++++++++++++ 1 file changed, 779 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-furni-names-from-json-server-liveness.md diff --git a/docs/superpowers/plans/2026-06-04-furni-names-from-json-server-liveness.md b/docs/superpowers/plans/2026-06-04-furni-names-from-json-server-liveness.md new file mode 100644 index 0000000..5ed0584 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-furni-names-from-json-server-liveness.md @@ -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([[ 5, floor ]]); + const wallItems = new Map(); + 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([[ 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, + wallItems: Map, + localization: Pick, + 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 + { + 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 entries; + + public FurnitureDataReloadComposer(int mode, List 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 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 first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")); + p.reindex(first); + List 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 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 reindex(java.util.List entries) { + Map 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 prev = this.index; + java.util.List delta = new java.util.ArrayList<>(); + for (Map.Entry 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 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=` buster to the loader fetch (optional follow-up).