Store messages to offline friends in messenger_offline (capped inbox),
replay on login via the existing FriendChatMessageComposer with an
"offline" extraData marker, and render a "sent while offline" tag in
the client thread. No new packets; emulator + small client touch.
Bite-sized TDD plan across all three codebases for the friend-groups
feature: 4 client->server category packets (renderer composers +
Arcturus handlers), DB persistence (reusing existing
messenger_categories + messenger_friendships.category), server-
authoritative re-push via existing MessengerInit/UpdateFriend
composers, and client store actions + chip-filter UI + manager modal +
per-friend assign control + pure filter helper with tests.
Earlier rev had the hand first, before the label. Feedback: the
label belongs at the very start of the strip; the hand reads
better as the first of the tool buttons it groups with. Same
gesture and exclusive-group behaviour, just visually:
Modalita disegno [hand] [SET][UNSET][UP][DOWN][DOOR] ...
Two related changes from the latest feedback:
1) Hand is now the FIRST button in the toolbar (left of the
'Modalita disegno' label), matching where users typically
look for a pan affordance in painting / mapping editors.
2) The hand and the brush buttons form one exclusive tool
group: picking any brush (SET / UNSET / UP / DOWN / DOOR)
- or select-all / square-select - clears pan mode. No more
'I clicked SET but the canvas keeps panning'. Same goes
the other way: clicking the hand stays sticky, and while
it's active the brush highlights are visually de-selected
even though state.brush.action still holds the last brush
(so the user gets it back the moment they pick a brush
again).
Implementation: replaced the toolbar's onTogglePanMode prop
with an imperative setPanMode(next: boolean) =>. Every other
tool's onClick calls exitPan() first; the hand calls
setPanMode(!panMode) directly. data-active and the border
highlight on the brush + square-select buttons now require
!panMode so the visual state mirrors the gesture state.
No reducer changes - panMode stays a canvas-level UI flag.
Feedback was the amber thumb looked generic / off-the-shelf
and didnt visually tie to the gradient. The thumb now picks
its fill from tileFill of the selected height, so picking 0
shows a blue bead, picking 12 a green one, picking 26 a
purple one, and so on across the full HEIGHT_SCHEME palette.
- Fill: radial gradient on the band colour with a soft white
highlight at top-left and a darker rim at the bottom-right
for a beaded look. The highlight intensity adapts to the
base colour (stronger on dark hues, dimmer on light) so
it never washes out.
- Text contrast: a perceptual-luma heuristic (Rec.601, plain
arithmetic, no colour lib) flips between text-zinc-900 and
text-white at the right threshold so the height number
stays legible on every colour the picker can land on. A
matching textShadow seals the deal on the borderline hues.
- Ring on drag is now zinc-900 + scale-110 (clear gesture
feedback even when the underlying colour is similar to
white).
- Test added: thumb fill at h=0 must differ from h=13, so any
future regression that pins the thumb to a single colour
fails the suite.
Two related polish improvements after the swatch-column → vertical-
slider swap.
Slider
- Wider track (18 px, was 14 px) for a more comfortable click area
with the same on-screen footprint.
- Min / max chips above and below the rail (HEIGHT_BRUSH_MIN /
_MAX) so users know which end is high and which is low without
hovering to discover.
- Thumb now uses a warm amber radial gradient (#fff7c4 → #facc15
→ #ca8a04) on a dark brown border with a soft drop shadow + inset
highlight, instead of the flat yellow disc. Hover adds a white
ring; drag swaps it for a darker ring — clear gesture feedback.
- Track gains a hover/drag glow (inset white seam + amber outline
via boxShadow) so you can tell the slider has focus before you
even click.
Hand tool (canvas pan)
- New FloorplanToolbar button (FaHandPaper, sticky toggle, emerald
fill when active) ties to a new state lifted into
FloorplanEditorView. When the hand is active, plain left-click
+ drag pans the canvas instead of brushing tiles. Cursor flips
to grab / grabbing accordingly.
- FloorplanCanvasSVG's isPanGesture predicate becomes:
middle-mouse OR Shift+left-click OR (panMode && left-click).
Shift / middle still work whether or not the hand is on so power
users keep their muscle memory.
- No change to the reducer (panMode is a canvas-level UI flag, not
a brush action — keeps state/types tight).
Replaces the SVG column of 27 colour swatches with a vertical
slider that fills the same role (pick a brush height 0-26) but
much faster to scrub:
- Track is a discrete-step linear gradient built from the real
tile-fill colours, top = HEIGHT_BRUSH_MAX, bottom =
HEIGHT_BRUSH_MIN. Each height occupies a clear band so the
user still reads colour-to-height at a glance.
- Yellow circular thumb shows the current value as a number,
centred at the picked height's band, with a darker border
while dragging so the drag affordance is obvious.
- Click anywhere on the track to jump; the same gesture starts
a drag (pointermove on window) so users can scrub up/down
without releasing. Pointer-cancel + button-other-than-0 are
handled.
- ARIA: slider role + valuemin / valuemax / valuenow, plus a
touch-none style so mobile scrolling doesn't fight the drag.
Tests rewritten around the new contract (5 cases):
- thumb renders with the current value;
- click at top -> picks 26;
- click at bottom -> picks 0;
- click at middle -> picks 13;
- click at the band that's already selected -> no onSelect
call (idempotent).
Track geometry is stubbed via getBoundingClientRect so the
pointer math is reproducible under jsdom. afterEach(cleanup)
keeps multiple renders from colliding on the data-testid lookup.
Complete modernization of the floor-plan editor. Three layered
changes shipped together since they share state shapes and the
test infrastructure stubs.
1) React rewrite (state + hooks + views + tests)
Drops the FloorplanEditorContext singleton + legacy view
components and replaces them with a pure-React reducer
architecture:
- state/ — typed FloorplanState + FloorplanAction union,
pure reducer covering PAINT_TILE / ERASE_TILE /
ADJUST_HEIGHT / SET_DOOR / SET_DOOR_DIR / SET_THICKNESS /
SET_WALL_HEIGHT / BRUSH_SET / SELECT_RECT / SELECT_ALL /
CLEAR_SELECTION / SQUARE_SELECT_TOGGLE / IMPORT_STRING /
APPLY_REMOTE_DIFF / APPLY_REMOTE_SNAPSHOT. Source-tagged
('local' | 'remote') so the editor can distinguish user
edits from server pushes. Co-located encoding helpers
(parseTilemap / serializeTilemap) and area-counter
selectors.
- hooks/ — useFloorplanReducer (wraps useReducer with a
history stack + loadFromServer + undo/redo), useTool
(pointer events -> dispatch), usePointerToTile (screen
-> tile projection that respects the viewBox origin so
pan/zoom stays accurate).
- views/ — FloorplanCanvasSVG, FloorplanHeightPicker,
FloorplanToolbar, FloorplanOptionsPanel,
FloorplanImportExport, FloorplanTile,
FloorplanPreviewSVG (alternative iso preview kept as a
fallback view, not wired into the main layout).
- Co-located Vitest suites for every module above (encoding,
reducer, selectors, hooks, views, integration). 100+ new
test cases.
2) Live in-room preview (NEW capability)
useFloorplanLiveSync drives client-side preview of the edit
directly into the active room — every tile / door / wall
height / thickness change is applied through
GetRoomMessageHandler().applyFloorModelLocally (new public
method on the renderer, see paired renderer PR) with
zero server traffic during editing. The wire
UpdateFloorPropertiesMessageComposer is only sent when the
user explicitly clicks Save. Thickness slider additionally
calls RoomEngine.updateRoomInstancePlaneThickness for
zero-latency wall/floor-depth feedback while dragging.
Toggle 'Live preview ON / OFF' in the bottom strip (default
ON) lets the user opt out if they want to keep changes
contained to the editor's own preview until Save.
Revert button re-applies the original snapshot locally so
the room snaps back to where it was when the editor opened.
3) UX polish
- Undo / Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y) backed by a
100-step history stack inside useFloorplanReducer. Local
mutating actions push history; brush/selection UI bumps
and remote dispatches bypass it; loadFromServer wipes the
stack.
- Zoom 40-600 % with Ctrl+wheel, +/- buttons, % label.
Shift+drag or middle-mouse drag pans the canvas.
- Auto-fit on first paint: computes the screen-space
bounding box of the painted (non-blocked) tiles, picks the
zoom that just contains them with a 5 % margin, pans so
the room sits in the viewport centre. Default view is now
'room fills the canvas' instead of 'room is a dot at the
top-centre of a huge empty canvas'. Clicking the % label
re-runs the fit; crosshair button keeps zoom and recentres
the pan only.
- Door direction control: arrows + door icon triplet
(8-way rotate by single click on prev/next, full cycle
forward on the icon itself). Wall and floor thickness
collapse from two 4-button rows into two compact
segmented selectors (active state in emerald). Saves
significant horizontal space.
- Habbo floor pattern tile (~186 B PNG, vendored from
habbofurni.com/images/furni_floor.png) tiled as the
canvas background with image-rendering: pixelated so the
texture stays crisp at every zoom level. Replaces the
solid black background.
Test infrastructure
nitro-renderer.mock grows constructors / proxies / functions
for everything the new floor-editor tests transitively
import (floor composers + events, RoomEngineEvent,
ILinkEventTracker, convertNumbersForSaving /
convertSettingToNumber, GetRoomMessageHandler,
GetTicker, GetRenderer, NitroTicker, RoomPreviewer with a
sufficiently real .updatePreviewModel / dispose surface,
and a TextureUtils.createRenderTexture that returns an
object with a no-op .destroy). test-setup adds a no-op
ResizeObserver polyfill (jsdom doesn't ship one and the
optional FloorplanRoomPreview observes its container) and
a draggable-windows-container portal root for tests that
mount NitroCardView.
Files: 44 changed (mostly new). yarn typecheck 0 errors,
yarn test 341/341 green.
CI on PR #157 was failing every typecheck with 35 TS2305 errors of the
form 'Module @nitrots/nitro-renderer has no exported member
Housekeeping*' because the workflow's PR fallback mapped to
duckietm/Nitro_Render_V3 @ Dev — and upstream Dev doesn't carry the HK
composers yet. They live on the fork's feat/housekeeping-packets
branch (renderer companion PR #77).
Detect the PR head ref and, when it's feat/housekeeping-panel,
override the default base-ref pairing and point at
simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets so the typecheck
step can resolve the imports. Once the renderer PR lands on
duckietm:Dev this whole special-case block becomes dead code and can
be deleted.
Two things in one commit because they sit on top of each other:
1. **Reset password reveal card.** The emulator's
`HousekeepingResetUserPasswordEvent` already returns the freshly
generated 12-char plaintext in the action-result `message`, but
the client was leaking it through the standard success-banner
pipeline — auto-dismiss in 4s, truncated, no copy button. Operators
were missing it.
- New `<HousekeepingPasswordReveal />` card mounted in the panel
header (between the status banner and tab content). Stays put
until manually dismissed.
- `useHousekeepingStore` gains a dedicated `passwordReveal` slot
(`{ userId, username, password }`) plus `revealPassword()` /
`clearPasswordReveal()` setters. Sensitive data, kept OUT of the
generic banner / toast pipeline.
- `useHousekeepingActions.resetUserPassword` no longer routes
through `wrap()` — it intercepts the result, lifts the
plaintext into the reveal slot, and uses a localizable success
key (`housekeeping.action.reset_password.done`) for the banner so
the password itself never lands there.
- Copy button uses `navigator.clipboard.writeText` in secure
contexts with a `document.execCommand('copy')` fallback for
http:// deployments. Confirmation icon flips to a checkmark for
~1.6s on success. The input is `select-all` + auto-select on
focus so Ctrl+C is also a manual fallback.
- 8 new i18n keys (EN + IT, .example + runtime UITexts.json5 /
UITexts.en.json5).
2. **Catalog admin cleanup ported from the PR branch.** The dev
branch was still carrying the catalog admin code (handlers, hooks,
store slots, i18n keys) even though the local renderer is on the
catalog-stripped `feat/housekeeping-packets` branch — typecheck
was breaking because the catalog composers no longer exist on the
linked renderer. Stripped here to match: 4 catalog actions
removed from `HousekeepingActionType`, `HousekeepingApi.ts`,
`useHousekeepingActions`, `useHousekeepingStore`. The CATALOG tab
id is gone from `HousekeepingTabId`. Catalog interfaces
(`IHousekeepingCatalogPage` / `IHousekeepingCatalogOffer`) are
dropped. 17 catalog i18n keys removed per locale. Two test files
updated to match.
Adds the Housekeeping in-client admin panel — a Modtools-adjacent
surface that runs entirely inside the React client, talking to the
emulator over the existing wire instead of a separate REST/CMS layer.
Surface:
- `src/components/housekeeping/` — panel shell + 5 tabs (Dashboard,
Users, Rooms, Economy, Audit). Each tab drives one domain of the
matching emulator handlers (find/sanction/admin/economy/catalog/
hotel-wide).
- `src/api/housekeeping/` — composer/parser orchestration:
`HousekeepingApi.ts` exposes 30+ typed actions, each one running
through `runHkAction()` which awaits the shared
`HousekeepingActionResultEvent` correlated by action key.
- `src/hooks/housekeeping/` — `useHousekeeping` (the public hook),
`useHousekeepingStore` (useBetween singleton: shared selection +
audit polling + sanction templates), `useHousekeepingActions`,
`useHousekeepingConfirm`.
- `src/api/nitro/awaitMessageEvent.ts` — Promise adapter over
`CommunicationManager.subscribeMessage` with a sync `select`
callback that snapshots the parser INSIDE the subscribe handler
before the renderer recycles the parser instance after the
Promise resolves.
- `public/configuration/housekeeping-texts-{en,it}.example` —
149 EN + 149 IT i18n keys under `housekeeping.*` for every panel
string + every server-side error slug the emulator may emit.
Wiring (additive only):
- `src/components/MainView.tsx` — `<HousekeepingView />` mounted
alongside `<ModToolsView />`.
- `src/api/index.ts`, `src/hooks/index.ts`, `src/api/nitro/index.ts`
— added the `housekeeping` and `awaitMessageEvent` re-exports.
Wire contract: pairs against the Arcturus PR (#120 on
duckietm/Arcturus-Morningstar-Extended) and the renderer PR (#77 on
duckietm/Nitro_Render_V3). Incoming events 9100..9129, outgoing
composers 9200..9207. Permission gate `acc_housekeeping` enforced
server-side; the panel is hidden client-side via
`housekeeping.enabled` in the runtime ui-config.
feat(loading): redesigned loader with progress bar, task labels, configurable assets + perf(build): granular code-split + preconnect hint for cold-load speed + docs: PERFORMANCE.md — client + server recipe for the 4s cold load
Standalone performance guide for the Nitro V3 client, covering both
sides of the cold-load story so a deployer doesn't have to cross-
reference two repos to get from 60-90 s down to 4 s.
Sections:
1. Three Nitro-side changes that matter (code split, LoadingView with
real progress, remember-token URL capture)
2. Vite manualChunks — why vendor-first ordering matters, why pixi
stays inlined, expected chunk sizes after yarn build
3. LoadingView state model + the 12-stage progress table + the
pre-React shell template in scripts/write-asset-loader.mjs
4. Remember-token capture from URL → SetRememberLogin, with DevTools
verification of nitro.auth.remember localStorage entry
5. nginx gzip (the single biggest win at ~17x for JSON5) + 30-day
cache headers on gamedata + try_files manifest fallback
6. Windows + IIS equivalent — URL Rewrite + ARR reverse proxy to
Node, Dynamic Compression toggle, web.config snippets,
trade-offs (CPU cost, JDBC quirks, shared hosting caveat)
7. End-to-end verification probes — chunks present, gzip on, dir
fallback works, progress bar renders, remember-token persisted
Cross-references medievalshell/InertiaCMS:docs/PERFORMANCE.md for the
matching CMS-side application config (SSO TTL, /api/auth/remember
endpoint, migrations).
The `[App] prepare() start` console.warn was including the full SSO
ticket from `window.location.search`. SSO tickets are one-shot bearer
credentials — any leak (copied logs in a bug report, screen share,
malicious browser extension reading console output) grants
single-use access to the user's session. Replace the actual ticket
with a boolean.
The vendor chunk was a single ~1MB blob (react + tanstack-query +
framer-motion + jodit + emoji-mart + react-icons + howler + zustand +
json5 all merged), forcing every cold load to wait on the slowest of
those modules before the page could interactivate. Split it into
domain-specific chunks so HTTP/2 multiplexing can pull them in
parallel and CF can cache each independently:
- vendor-pixi (pixi.js + pixi-filters — when rollup actually splits;
currently inlined into the umbrella renderer chunk
because nitro-renderer is its sole importer)
- vendor-audio (howler)
- vendor-emoji (@emoji-mart — heaviest at ~430KB, only used in chat
so a longer-term win is making it lazy)
- vendor-editor (jodit + @react-page — admin-only news editor)
- vendor-react (react / react-dom / scheduler / error-boundary)
- vendor-motion / vendor-query / vendor-icons / vendor-state /
vendor-json5
- nitro-renderer-{avatar,communication,room,assets} — heaviest
renderer packages get their own chunks when imported directly
(the umbrella @nitrots/nitro-renderer still hosts the rest)
Also add a `<link rel=preconnect>` for challenges.cloudflare.com so
the Turnstile JS handshake doesn't pay an extra TLS round-trip on
the first paint.
Net effect: roughly the same total bytes shipped on a cold load, but
they fetch in parallel instead of sequentially, and a warm second
visitor only re-downloads the chunks whose code actually changed.
The CMS Inertia /client page now passes `&token=<uuid>&token_exp=<unix>`
on the iframe src so Nitro can persist the token to localStorage on first
boot. `App.tsx::prepare()` reads them from `window.location.search` and
calls `SetRememberLogin({ token, expiresAt })` when no remember-login is
already stored.
This wires up the existing reconnect flow: when the WS drops, the loop
in `tryRememberLogin()` (already in this file) POSTs the saved token to
`login.remember.endpoint` (defaults to `${api.url}/api/auth/remember`)
and uses the returned fresh SSO ticket to reconnect. Without this step
the localStorage stayed empty and the reconnect always fell through to
"Session expired" after a few retries because Arcturus clears
`auth_ticket` on first consume.
Server side: the CMS counterpart is in medievalshell/InertiaCMS
commit on djoohotel — adds the /api/auth/remember endpoint backed by
`users_remember_families` (UUID family + 30-day expiry + revoked flag).
`useCatalogActions` filter (useCatalog.ts:1042-1043) destructures and
returns `getNodesByOfferId` from the store along with the other action
methods. The filter contract test was stale — it asserted 11 keys while
the actual filter returns 12.
Add `getNodesByOfferId` to the expected keys list and to the fakeStore
mock so the assertion matches the live hook output.
Two pre-existing tsgo failures surfaced by the loading-screen redesign PR:
1. AvatarEffectsView: import loadGamedata via the umbrella
`@nitrots/nitro-renderer` instead of `@nitrots/utils`. The deep
sub-package alias only exists in vite.config.mjs; tsgo resolves
against node_modules, where only the umbrella is symlinked. Same
symbol — index.ts re-exports `* from '@nitrots/utils'`.
2. DraggableWindow: `useRef<HTMLDivElement>()` -> `useRef<HTMLDivElement>(null)`.
React 19 typings now require an initial value. Fixed once in
a39aa37, re-introduced by the merge in 03bebe4.
Loading screen overhaul:
- LoadingView: Nitro V3 logo flush top-left, loading.gif at viewport
centre, large progress bar (max 900px / 90vw, h-8, gradient + glow)
anchored bottom-centre with the percentage rendered inside the bar in
Poppins, plus a friendly stage label underneath. Logo + background +
progress bar colour overridable via renderer-config keys
(loading.logo.url, loading.background, loading.progress.color).
- App.tsx: wired a real loadingProgress (0->100) + loadingTask driven by
the boot pipeline: config init (10), renderer (20), per-warmup-task
bumps for AssetManager/Localization/AvatarRender/SoundManager (25->70),
session managers (78/85/92), Communication (98), ready (100). Each bump
carries a task label looked up via a new taskLabel(key, fallback)
helper so the Italian baseline ("Sto caricando il guardaroba",
"Connessione al server", ...) can be translated by editing
renderer-config; fallback keeps current strings if the key is missing.
- AvatarEffectsView: replace raw fetch(url).json() with
loadGamedata(url) so the effectmap root manifest (JSON5 with
// comments) parses correctly and supports the core/custom/seasonal
tier merge.
- fallbackToLogin: respect login.screen.enabled=false. When login is
disabled (SSO-only deployments), init failures now route to
showSessionExpired() (home + diagnostic) instead of rendering an empty
LoginView placeholder.
- scripts/write-asset-loader.mjs: the pre-React shell rendered into
#root before the JS bundle takes over was a light-blue login skeleton
(linear gradient + two grey rectangles) producing a visible flash
before the real loader appeared. Replaced with the same
radial-gradient the LoadingView paints — the handoff is now invisible.
- renderer-config.example: document the 13 loader keys so operators can
copy & translate.
When a new CFH ticket arrives the moderator currently only finds out
by opening the ModTools launcher and looking at the Report Tool
counter. If the launcher is closed they have no signal — same
treatment friend requests already get on the People button next door.
Match the existing pattern: read `tickets` from `useModTools()`
(useBetween-shared, no extra subscription cost), filter to
state===1 (OPEN), and render a <LayoutItemCountView> over the
ToolbarItemView in absolute-positioned relative wrapper. Same
positioning as the friend-requests badge (-right-1 -top-1 z-10
pointer-events-none).
Gated on `isMod` so non-mods don't compute the filter or render
the wrapper — and since useModTools is a useBetween singleton its
event listeners only register once across the whole app regardless
of consumer count.
Applied to both toolbar layouts (desktop and mobile, lines ~272 and
~382) so the badge follows the user across breakpoints.