VaultView wired to the Earnings Center packets: requests data on open, renders
real amounts/claimable per category from EarningsCenterEvent, Riscatta + Richiedili
Tutti send the claim composers, refreshes on EarningsClaimResultEvent. Category
keys match the emulator contract; reward currencies derived from reward type.
Adds the real earnings_icon assets. Wired into MainView.
When a furni has a matching furnidata entry with a display name but its
items_base.public_name (the DB fallback) is empty, the editor now shows a
'Sync from furnidata' button next to the Public Name field. It reuses the
generic item update (a partial { publicName } payload) to fill the DB column
from the stored furnidata name, so the read-only fallback stops being blank.
Button shows only when the entry's classname matches, the DB field is empty,
and the furnidata name is present; it disappears after the sync re-fetch.
When creating a furnidata entry for a furni whose sprite id is already used
by another classname, the server rejects with ID_COLLISION. The client showed
the optimistic public-name change but never reverted it, so a failed create
looked like it had succeeded. On any FurniEditorResult failure, re-fetch the
item detail to restore the true state (the error alert was already shown).
Captures the session learning: the client can't be verified headless (WebGL
hangs + SSO login); use the Claude-in-Chrome extension on the real logged-in
localhost:5173 session (shared SSO cookie auto-logs-in, real GPU). Toolbar
buttons are canvas → locate by screenshot + click by coordinate.
The "price raised" (case 3) handler did `set(item.offerId, item)` then an
unconditional `delete(requestedOfferId)`. When the re-priced offer kept the same
id (offerId === requestedOfferId), the set was immediately undone and the offer
disappeared from the list though it was still buyable. Delete the old key first,
then set under the new id.
redeemSoldOffers optimistically removed the sold offers but never reset
creditsWaiting, so the redeem panel (gated on creditsWaiting > 0) kept rendering
"get 0 sold items for N credits" with an active Redeem button until the server
re-pushed the offers. Reset creditsWaiting to 0 on redeem.
clubStatus computed `(purse.pastVipDays > 0) || (purse.pastVipDays > 0)` — the
same field twice. The first operand was meant to be `pastClubDays`, so a user who
previously had Habbo Club but never VIP got ClubStatus.NONE instead of EXPIRED,
showing the wrong HC Center status text and Buy-vs-Extend button. Use pastClubDays.
The selected-swatch ring compared each swatch against `groupData.groupColors`,
which is only written back on save — so clicking a colour updated the local
`colors` state (and the top preview swatches) but the highlight ring never moved,
leaving the two in visible disagreement until you left and reopened the tab.
Compare against `colors[0]`/`colors[1]` (the live state) instead.
When the roll hit CAMERA_ROLL_LIMIT the capture path alerted "full" then did
clone.pop() (dropping the NEWEST photo) and pushed the new one — so once full the
roll was pinned at the limit and every further shot just replaced the most-recent
picture. Block the shot instead (return after the alert).
The 'delete' action spliced the selected picture out of the roll but never moved
selectedPictureIndex, so it kept pointing at the slot the deleted photo vacated —
now a different picture (or past the end), making the preview show the wrong
photo or vanish while the UI still thinks one is selected. Move the selection
back one after delete.
The ModeratorActionResultMessageEvent alerts (hardcoded, not LocalizeText) read
"successfull" and "applying tht moderation action". Fix to "successful" / "the".
sendDefaultSanction passed `selectedTopic` (the index into the topics array) as
the CFH topic id to DefaultSanctionMessageComposer, so the emulator applied the
default sanction against whatever topic happened to sit at that array position.
The sibling sendSanction (and the topic label) correctly use
`topics[selectedTopic].id`. Pass `category.id`, and move the `category` lookup
after the `selectedTopic === -1` guard (+ a `!category` guard).
The save guard `if(startDateInstance && endDateInstance)` is always true — a bad
input parses to a truthy *Invalid Date*, so `getTime()/1000` wrote NaN into the
int params. A never-configured furni was worse: the read effect only seeded the
inputs when `intData.length >= 2`, so the first save sent `new Date('')` → NaN.
Guard on `isNaN(getTime())` and seed both inputs to "now" for the empty case.
`liveState.altitude` is already z*100, but the @altitude inspector rows multiplied
by 100 again (showing z*10000). The edit-commit path parses the field back as
`parsed / 100` (expecting z*100), so the displayed value and the edit parser
disagreed: tweaking the shown number wrote a wildly wrong altitude (clamped to the
40 ceiling). Display the raw `altitude` so it round-trips with the editor.
The 1s tick did `value.secondsLeft--` on the VoteValue objects held by the
previous state Map and returned the same Map reference when nothing expired — an
in-place state mutation (breaks memoization / StrictMode double-invoke). Rebuild
new value objects in a new Map each tick (and drop expired entries).
Two bugs in the pet rebreed/fertilize chat path: the localization key had a
stray trailing semicolon (`'widget.chatbubble.petrefertilized;'`) so the lookup
failed and the raw key rendered; and the target user was resolved via
`newRoomObject = getRoomObject(event.extraParam)` but then read back by
`roomObject.id` (the speaker), so `userName` was the speaker, not the target.
Use `newRoomObject.id`.
The USER_REMOVED handler for a UNIT filtered name bubbles with
`bubble.roomIndex === event.id`, which KEEPS only the leaving user's bubble and
drops everyone else's (the two sibling filters on the next lines correctly use
`!==`). When any user left view, all other floating name bubbles vanished while
the departed user's lingered. Flip to `!==`.
FriendsMessengerThreadView called `thread.setRead()` in the render body — a side
effect during render that mutates the MessengerThread instance the messenger hook
also reads to compute the unread indicator, making unread state order-dependent
(and it would NPE if `thread` was null). Move it into a no-dependency useEffect
(runs after every commit, same cadence as before) and guard the null thread.
sendActiveBadges incremented pendingUpdatesRef on every edit, and the BadgesEvent
handler skipped applying the server's active badges while the counter was > 0,
decrementing once per BadgesEvent. The assumption was "one BadgesEvent echo per
SetActivatedBadges" — but the emulator's UserWearBadgeEvent answers with a
UserBadgesComposer room broadcast, NOT a BadgesEvent, so nothing ever decrements
the counter. It leaks upward with every toggle/reorder/swap and then silently
drops legitimate later BadgesEvent updates (the server-authoritative active set
never reapplies). Remove the counter and always apply the server's active badges
on BadgesEvent (edits are already persisted server-side, so this is correct).
The ActivePrefixUpdatedEvent handler set the active prefix via
`setActivePrefix(prev => { const found = prefixes.find(...) })`, reading the
`prefixes` state from the closure — which lags by a render and is stale/empty when
the prefix was added earlier in the same event batch, so `found` was undefined and
the active prefix fell back to a partial item missing icon/color/displayName. Move
the derivation inside the `setPrefixes` updater so it reads the freshly-mapped list.
resetItems/removeUnseen/the UnseenItemsEvent handler each did `new Map(prevValue)`
(a shallow copy) then spliced/pushed the per-category array returned by
`.get(category)` — the SAME array reference still held by the previous Map. That
mutates state outside React's data flow (breaks under StrictMode double-invoke and
any updater replay). resetItems additionally did `splice(existing.indexOf(id), 1)`
with no guard, so an id not present (indexOf === -1) spliced off the wrong LAST
element. Replace each in-place splice/push with a cloned array set back on the new
Map (filter for removals, spread+push for the merge).
When a furni has no furnidata entry (furniDataEntry === null), unlock the
name/description fields instead of locking them: the Save button becomes
"Create entry" and sends the existing FurniEditorUpdateFurnidataComposer (10046),
which the emulator now upserts (creates a complete entry from items_base). The
classname-mismatch case (entry resolved by id but for a different classname)
stays locked to avoid an id collision. On success the hook already re-fetches the
detail, so the panel flips to normal edit mode. Name input prefills (placeholder)
from the DB Public Name.