Messenger Phase 1 — Friend Groups 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: Add full custom friend groups (create / rename / delete / assign) to the existing React friends list, with an Online/Offline-primary view plus a per-group chip filter.
Architecture: Four new client→server packets (renderer composers + Arcturus handlers) drive category CRUD + friend assignment. The server persists to the existing messenger_categories table and the messenger_friendships.category column, then re-pushes authoritative state through the existingMessengerInitComposer (category list) and UpdateFriendComposer (a friend's new categoryId). The client already receives categories via MessengerInitEvent and categoryId per friend — we add CRUD actions + UI and a pure group-filter helper.
Renderer Outgoing header N == Arcturus Incoming header N (verified across 8 existing friend packets, e.g. SET_RELATIONSHIP_STATUS = 3768 == ChangeRelationEvent = 3768). Phase 1 reuses existing server→client headers, so it needs 4 new client→server header IDs only, used identically in OutgoingHeader.ts (renderer) and Incoming.java (Arcturus):
Task 1: Allocate & verify the 4 category header IDs
Files: none yet (decision + verification only).
Step 1: Try official IDs, then pick verified-free fallbacks
The user prefers official Habbo revision IDs for category packets. First check whether the connecting revision shipped friend-category management packets:
Run (renderer): grep -rin "categor" Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts
Expected: only MESSENGER_* friend headers, no add/rename/remove/move-category constant.
If no official category constants are found (expected — the bundled revision's OutgoingHeader.ts has none), use the custom fallback quartet 4081, 4082, 4083, 4084 and verify they are free on BOTH sides.
Step 2: Verify the four numbers are unused on both sides
Expected: no output from either command (the IDs are free).
If either prints a match, increment the base (try 4085–4088, etc.) and re-run until both commands return nothing. Record the final quartet here before continuing:
All later tasks reference the constant names, so only Tasks 2 and 4 (the constant definitions) carry the raw numbers. If you changed the numbers above, use your values in Tasks 2 and 4.
Step 3: No commit (decision task — nothing changed on disk yet).
Run: cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts
Expected: FAIL — cannot find module ../AddFriendCategoryComposer (files not created yet).
Step 3: Create the four composers
AddFriendCategoryComposer.ts:
import { IMessageComposer } from '@nitrots/api';
export class AddFriendCategoryComposer implements IMessageComposer<ConstructorParameters<typeof AddFriendCategoryComposer>>
{
private _data: ConstructorParameters<typeof AddFriendCategoryComposer>;
constructor(name: string)
{
this._data = [ name ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
RenameFriendCategoryComposer.ts:
import { IMessageComposer } from '@nitrots/api';
export class RenameFriendCategoryComposer implements IMessageComposer<ConstructorParameters<typeof RenameFriendCategoryComposer>>
{
private _data: ConstructorParameters<typeof RenameFriendCategoryComposer>;
constructor(categoryId: number, name: string)
{
this._data = [ categoryId, name ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
RemoveFriendCategoryComposer.ts:
import { IMessageComposer } from '@nitrots/api';
export class RemoveFriendCategoryComposer implements IMessageComposer<ConstructorParameters<typeof RemoveFriendCategoryComposer>>
{
private _data: ConstructorParameters<typeof RemoveFriendCategoryComposer>;
constructor(categoryId: number)
{
this._data = [ categoryId ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
Run: cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts
Expected: PASS (4 tests).
Step 5: Add the four header constants
In OutgoingHeader.ts, next to the other friend headers (after public static FRIEND_LIST_UPDATE = 1419;), add (use your Task 1 numbers):
public static ADD_FRIEND_CATEGORY = 4081;
public static RENAME_FRIEND_CATEGORY = 4082;
public static REMOVE_FRIEND_CATEGORY = 4083;
public static MOVE_FRIEND_TO_CATEGORY = 4084;
Step 6: Export the composers from the friendlist barrel
In messages/outgoing/friendlist/index.ts, add:
export * from './AddFriendCategoryComposer';
export * from './MoveFriendToCategoryComposer';
export * from './RemoveFriendCategoryComposer';
export * from './RenameFriendCategoryComposer';
Step 7: Register the composers in NitroMessages
First find how friendlist composers are imported in NitroMessages.ts:
Run: grep -n "SendMessageComposer" Nitro_Render_V3/packages/communication/src/NitroMessages.ts
This shows both the import line and the _composers.set(...) line.
Add the four classes to that same import statement (the one that imports SendMessageComposer, SetRelationshipStatusComposer, etc.). Then, next to this._composers.set(OutgoingHeader.SET_RELATIONSHIP_STATUS, SetRelationshipStatusComposer);, add:
The emulator has no unit-test infrastructure (confirmed: no src/test, no JUnit in pom.xml). Verification for Tasks 3–4 is a successful mvn package + the manual integration checklist in Task 11.
Step 1: Add setCategoryId to MessengerBuddy
MessengerBuddy already has private int categoryId = 0;, private int userOne = 0; (the owner's id), this.id (the friend's id), and getCategoryId(). Mirror the existing setRelation/run() DB idiom. Add after getCategoryId() (around line 141):
public void setCategoryId(int categoryId) {
this.categoryId = categoryId;
final int cat = categoryId;
final int owner = this.userOne;
final int friend = this.id;
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE messenger_friendships SET category = ? WHERE user_one_id = ? AND user_two_id = ?")) {
statement.setInt(1, cat);
statement.setInt(2, owner);
statement.setInt(3, friend);
statement.execute();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
});
}
(Connection, PreparedStatement, SQLException, Emulator, and LOGGER are already imported in this file.)
Step 2: Add renameMessengerCategory to HabboInfo
HabboInfo already has loadMessengerCategories(), addMessengerCategory(MessengerCategory) (INSERT + sets generated id), deleteMessengerCategory(MessengerCategory) (removes + DELETE via SqlQueries.update), and getMessengerCategories(). Add a rename helper after deleteMessengerCategory (around line 238), reusing the same SqlQueries.update idiom:
public void renameMessengerCategory(int categoryId, String name) {
for (MessengerCategory category : this.messengerCategories) {
if (category.getId() == categoryId) {
category.setName(name);
break;
}
}
try {
SqlQueries.update("UPDATE messenger_categories SET name = ? WHERE id = ? AND user_id = ?", name, categoryId, this.id);
} catch (SqlQueries.DataAccessException e) {
LOGGER.error("Caught SQL exception", e);
}
}
(SqlQueries, MessengerCategory, and LOGGER are already imported/available in this file.)
Step 3: Compile
Run: cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile
Expected: BUILD SUCCESS (no compile errors).
Step 1: Add the 4 header constants to Incoming.java
Next to the other friend constants (e.g. after public static final int InviteFriendsEvent = 1276;), add (use your Task 1 numbers — must match the renderer):
public static final int AddFriendCategoryEvent = 4081;
public static final int RenameFriendCategoryEvent = 4082;
public static final int RemoveFriendCategoryEvent = 4083;
public static final int MoveFriendToCategoryEvent = 4084;
Step 2: Create AddFriendCategoryEvent
Caps: ≤ 20 categories/user, name 1–25 chars, case-insensitive de-dupe. Persists via the existing HabboInfo.addMessengerCategory (which sets the generated id), then re-pushes the category list with the existing MessengerInitComposer.
package com.eu.habbo.messages.incoming.friends;
import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer;
public class AddFriendCategoryEvent extends MessageHandler {
@Override
public void handle() throws Exception {
String name = this.packet.readString();
Habbo habbo = this.client.getHabbo();
if (habbo == null || name == null) return;
name = name.trim();
if (name.isEmpty() || name.length() > 25) return;
if (habbo.getHabboInfo().getMessengerCategories().size() >= 20) return;
for (MessengerCategory existing : habbo.getHabboInfo().getMessengerCategories()) {
if (existing.getName().equalsIgnoreCase(name)) return;
}
MessengerCategory category = new MessengerCategory(name, habbo.getHabboInfo().getId(), 0);
habbo.getHabboInfo().addMessengerCategory(category);
this.client.sendResponse(new MessengerInitComposer(habbo));
}
}
Step 3: Create RenameFriendCategoryEvent
package com.eu.habbo.messages.incoming.friends;
import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer;
public class RenameFriendCategoryEvent extends MessageHandler {
@Override
public void handle() throws Exception {
int categoryId = this.packet.readInt();
String name = this.packet.readString();
Habbo habbo = this.client.getHabbo();
if (habbo == null || name == null) return;
name = name.trim();
if (name.isEmpty() || name.length() > 25) return;
boolean found = false;
for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) {
if (category.getId() == categoryId) {
found = true;
break;
}
}
if (!found) return;
habbo.getHabboInfo().renameMessengerCategory(categoryId, name);
this.client.sendResponse(new MessengerInitComposer(habbo));
}
}
Step 4: Create RemoveFriendCategoryEvent
Deleting a group resets its members to category 0, pushing each via the existing UpdateFriendComposer, then re-pushes the (now shorter) category list.
package com.eu.habbo.messages.incoming.friends;
import com.eu.habbo.habbohotel.messenger.MessengerBuddy;
import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer;
import com.eu.habbo.messages.outgoing.friends.UpdateFriendComposer;
public class RemoveFriendCategoryEvent extends MessageHandler {
@Override
public void handle() throws Exception {
int categoryId = this.packet.readInt();
Habbo habbo = this.client.getHabbo();
if (habbo == null) return;
MessengerCategory target = null;
for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) {
if (category.getId() == categoryId) {
target = category;
break;
}
}
if (target == null) return;
habbo.getHabboInfo().deleteMessengerCategory(target);
for (MessengerBuddy buddy : habbo.getMessenger().getFriends().values()) {
if (buddy.getCategoryId() == categoryId) {
buddy.setCategoryId(0);
this.client.sendResponse(new UpdateFriendComposer(habbo, buddy, 0));
}
}
this.client.sendResponse(new MessengerInitComposer(habbo));
}
}
Step 5: Create MoveFriendToCategoryEvent
categoryId == 0 means "uncategorized"; any other id must be an existing category. Persists via MessengerBuddy.setCategoryId and pushes the updated friend via UpdateFriendComposer.
package com.eu.habbo.messages.incoming.friends;
import com.eu.habbo.habbohotel.messenger.MessengerBuddy;
import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.UpdateFriendComposer;
public class MoveFriendToCategoryEvent extends MessageHandler {
@Override
public void handle() throws Exception {
int friendId = this.packet.readInt();
int categoryId = this.packet.readInt();
Habbo habbo = this.client.getHabbo();
if (habbo == null) return;
MessengerBuddy buddy = habbo.getMessenger().getFriends().get(friendId);
if (buddy == null) return;
if (categoryId != 0) {
boolean exists = false;
for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) {
if (category.getId() == categoryId) {
exists = true;
break;
}
}
if (!exists) return;
}
buddy.setCategoryId(categoryId);
this.client.sendResponse(new UpdateFriendComposer(habbo, buddy, 0));
}
}
Step 6: Register the 4 handlers
In PacketManager.java, inside registerFriends(), add:
(If PacketManager.java already uses a wildcard import com.eu.habbo.messages.incoming.friends.*;, skip the explicit imports — check with grep -n "incoming.friends" PacketManager.java first.)
Step 7: Build the fat jar
Run: cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
Expected: BUILD SUCCESS; jar produced under target/. A failure here most likely means a duplicate header (the registerHandler guard throws Header already registered) — return to Task 1 and pick different free IDs.
import { describe, expect, it } from 'vitest';
import { MessengerFriend } from './MessengerFriend';
import { countFriendsByCategory, filterFriendsByCategory } from './friendCategory.helpers';
const makeFriend = (id: number, categoryId: number): MessengerFriend =>
{
const friend = new MessengerFriend();
friend.id = id;
friend.categoryId = categoryId;
return friend;
};
describe('filterFriendsByCategory', () =>
{
const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5), makeFriend(4, 8) ];
it('returns all friends when categoryId is 0 (All)', () =>
{
expect(filterFriendsByCategory(friends, 0)).toHaveLength(4);
});
it('returns only the friends in the given category', () =>
{
expect(filterFriendsByCategory(friends, 5).map(f => f.id)).toEqual([ 2, 3 ]);
});
it('returns an empty array for a category with no members', () =>
{
expect(filterFriendsByCategory(friends, 99)).toEqual([]);
});
it('is null-safe', () =>
{
expect(filterFriendsByCategory(null, 5)).toEqual([]);
});
});
describe('countFriendsByCategory', () =>
{
const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5) ];
it('counts members per category id', () =>
{
const counts = countFriendsByCategory(friends);
expect(counts.get(0)).toBe(1);
expect(counts.get(5)).toBe(2);
});
it('is null-safe', () =>
{
expect(countFriendsByCategory(null).size).toBe(0);
});
});
Step 2: Run it to confirm it fails
Run: cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.ts
Expected: FAIL — cannot resolve ./friendCategory.helpers.
Step 3: Implement the helper
friendCategory.helpers.ts:
import { MessengerFriend } from './MessengerFriend';
/**
* Filter a friend list to a single category. categoryId 0 means
* "All" (no filtering) and returns the list unchanged.
*/
export const filterFriendsByCategory = (friends: MessengerFriend[], categoryId: number): MessengerFriend[] =>
{
if(!friends) return [];
if(!categoryId) return friends;
return friends.filter(friend => (friend.categoryId === categoryId));
};
/**
* Count how many friends belong to each category id. Used to render
* member counts on the group chips.
*/
export const countFriendsByCategory = (friends: MessengerFriend[]): Map<number, number> =>
{
const counts = new Map<number, number>();
if(!friends) return counts;
for(const friend of friends)
{
counts.set(friend.categoryId, (counts.get(friend.categoryId) ?? 0) + 1);
}
return counts;
};
Step 4: Run the test to confirm it passes
Run: cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.ts
Expected: PASS (6 cases).
Task 6: Client — category CRUD + assign actions in the friends store
Files:
Modify: Nitro-V3/src/hooks/friends/useFriends.ts
The store re-pushes authoritative state through the server (the existing MessengerInitEvent handler updates settings.categories; FriendListUpdateEvent updates each friend's categoryId), so these actions are thin send-wrappers — no optimistic local mutation.
Step 1: Import the new composers
In the top import from @nitrots/nitro-renderer in useFriends.ts, add AddFriendCategoryComposer, MoveFriendToCategoryComposer, RemoveFriendCategoryComposer, RenameFriendCategoryComposer to the named-import list.
Step 2: Add the four actions inside useFriendsStore
Add alongside followFriend / updateRelationship (around line 42), before the return:
It renders an "All" chip plus one chip per category (with member count), and a gear button to open the manager (wired in Task 8). It is a controlled component: parent owns selectedCategoryId.
(If Flex is not exported from ../../../../common, check the import other friends views use — FriendsListView.tsx already imports Flex, Button from ../../../../common; match that path.)
Step 2: Wire the chip row + filtering into FriendsListView
In FriendsListView.tsx:
Add imports:
import { useState } from 'react';
import { filterFriendsByCategory } from '../../../../api';
import { FriendsListGroupChipsView } from './FriendsListGroupChipsView';
import { FriendsCategoryManagerView } from './FriendsCategoryManagerView';
(useState may already be imported — merge into the existing react import. FriendsCategoryManagerView is created in Task 8; this import is fine to add now and will resolve after Task 8.)
Pull settings from the friends state hook used in this component (it already destructures from useFriendsState/useFriends). Ensure settings (and friends for the unfiltered counts) are in scope:
Run: cd Nitro-V3 && yarn typecheck
Expected: clean (a TS2307 for FriendsCategoryManagerView is acceptable here only until Task 8 lands; if you implement Task 8 next there should be no errors). If you want a green checkpoint now, temporarily comment the manager import + render block, then restore in Task 8.
Step 4: Commit
cd Nitro-V3
git add src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx src/components/friends/views/friends-list/FriendsListView.tsx
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): group chip-filter row over online/offline list"
Verify imports against the codebase.Column, Flex, Button, and the NitroCard* components come from ../../../../common; confirm the exact set with grep -n "from '../../../../common'" src/components/friends/views/friends-list/FriendsListView.tsx. The localization keys above (generic.create / generic.save / generic.edit / generic.delete) are placeholders for whatever your locale files already define — if a key renders raw, swap it for an existing one or add it to the locale JSON. The icon-edit / icon-deselect spritesheet classes: reuse whatever exists in FriendsView.css; if absent, use a plain text label ("✎" / "✕") instead.
Step 2: Type-check + test
Run: cd Nitro-V3 && yarn typecheck && yarn test --run
Expected: clean; tests green. (If you commented the manager wiring in Task 7 Step 3, restore it now.)
Step 3: Commit
cd Nitro-V3
git add src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx src/components/friends/views/friends-list/FriendsListView.tsx
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): create/rename/delete group manager"
Task 9: Client — per-friend "assign to group" control
At the top of the component body, pull categories from state and the move action from actions:
import { useFriendsActions, useFriendsState } from '../../../../../hooks';
(Match the existing relative depth — this file is one level deeper than FriendsListView.tsx, so it's ../../../../../hooks; confirm with the file's existing imports.)
(useState is likely already imported; if not, add it.)
Step 2: Add the assign control to the actions row
In the friends-list-actions div (currently the follow / chat / relationship icons), add — only when the friend is a real user (friend.id > 0) and at least one category exists — a group icon that toggles a small menu:
(Reuse an existing spritesheet class for icon-group if one fits, or fall back to a text glyph. The "uncategorized" entry uses friend.categoryId === 0.)
Step 3: Type-check + test
Run: cd Nitro-V3 && yarn typecheck && yarn test --run
Expected: clean; tests green.
Run: cd Nitro-V3 && yarn typecheck
Expected: clean (CSS isn't type-checked, but confirm nothing else broke).
Step 3: Commit
cd Nitro-V3
git add src/css/friends/FriendsView.css
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "style(friends): group chips, category manager, assign menu"
Task 11: Integration verification (two live sessions)
Files: none (manual verification + final checks). Fix-up commits only if something fails.
Step 1: Apply the DB-side prerequisite check
The messenger_categories and messenger_friendships.category schema already exist (verified). Confirm against the running DB:
SELECT COUNT(*) FROM messenger_categories;
SHOW COLUMNS FROM messenger_friendships LIKE 'category';
(Per house rules, if a destructive statement is ever needed, hand the user a one-liner to run under E:\laragon\bin\mysql\... — but nothing destructive is required here.)
Step 2: Run the new emulator jar + client dev server
Emulator: run the jar built in Task 4 (java -jar Arcturus-Morningstar-Extended/Emulator/target/Habbo-*-jar-with-dependencies.jar).
Client: cd Nitro-V3 && yarn start (Vite picks up the renderer source live — no renderer build needed).
Step 3: Manual test matrix (log in, open Friends)
Verify each:
Create group: open the manager (⚙), type a name, Create → a new chip appears (server re-pushed MessengerInit).
Rename group: edit a category name → chip label updates after round-trip.
Delete group: delete a category → chip disappears; any friend that was in it shows as uncategorized (its assign menu no longer marks that group active).
Assign friend: open a friend's group menu, pick a group → switch the chip filter to that group and confirm the friend appears there; switch to "All" and confirm they still appear once.
Filter: click each chip → only that group's friends show in both Online and Offline sections; counts in headers match; "All" shows everyone.
Caps: creating a 21st group is rejected (Create disabled at 20); a >25-char name is truncated by maxLength/server.
Persistence: relog → groups and assignments survive (DB-backed).
No regressions: follow, chat, relationship icons, requests, search, room-invite, remove-friend still work.
Step 4: Final automated checks
Run:
cd Nitro_Render_V3 && yarn compile:fast && yarn test --run
cd Nitro-V3 && yarn typecheck && yarn test --run && yarn eslint
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
Expected: renderer tests green (142), client tests green (existing + 6 new helper cases), client typecheck + eslint clean, emulator BUILD SUCCESS.
Step 5: Commit any fix-ups
# only if Step 3/4 required changes
cd Nitro-V3
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(friends): integration fixes for friend groups"
Notes for the next phase
Phase 2 (offline messages) reuses the same FriendChatMessageComposer replay path — no new packets — and the messenger_offline table that already exists.
Phases 3–4 (read receipts, typing) will add custom packets following the same renderer-composer/Arcturus-handler pattern proven here, plus a new server→client event+parser (mirror NewConsoleMessageEvent/NewConsoleMessageParser) which this phase did not need.
Do NOT push automatically (the auto-push rule is scoped to the react19 branches only). Open PRs against the correct base per the duckietm --base dev/Dev convention when the user asks.
Messenger Phase 1 — Friend Groups Implementation Plan
Goal: Add full custom friend groups (create / rename / delete / assign) to the existing React friends list, with an Online/Offline-primary view plus a per-group chip filter.
Architecture: Four new client→server packets (renderer composers + Arcturus handlers) drive category CRUD + friend assignment. The server persists to the existing
messenger_categoriestable and themessenger_friendships.categorycolumn, then re-pushes authoritative state through the existingMessengerInitComposer(category list) andUpdateFriendComposer(a friend's newcategoryId). The client already receivescategoriesviaMessengerInitEventandcategoryIdper friend — we add CRUD actions + UI and a pure group-filter helper.Tech Stack: Arcturus (Java 21/Maven/HikariCP), Nitro_Render_V3 (TypeScript, yarn workspaces, Vitest), Nitro-V3 (React 19, Vite, Vitest).
Cross-codebase header-ID contract
Renderer Outgoing header N == Arcturus Incoming header N (verified across 8 existing friend packets, e.g.
SET_RELATIONSHIP_STATUS = 3768 == ChangeRelationEvent = 3768). Phase 1 reuses existing server→client headers, so it needs 4 new client→server header IDs only, used identically inOutgoingHeader.ts(renderer) andIncoming.java(Arcturus):AddFriendCategoryComposer(name)AddFriendCategoryEventADD_FRIEND_CATEGORY/AddFriendCategoryEventRenameFriendCategoryComposer(categoryId, name)RenameFriendCategoryEventRENAME_FRIEND_CATEGORY/RenameFriendCategoryEventRemoveFriendCategoryComposer(categoryId)RemoveFriendCategoryEventREMOVE_FRIEND_CATEGORY/RemoveFriendCategoryEventMoveFriendToCategoryComposer(friendId, categoryId)MoveFriendToCategoryEventMOVE_FRIEND_TO_CATEGORY/MoveFriendToCategoryEventThe concrete numbers are chosen and verified in Task 1.
File map
Arcturus (
Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/):messages/incoming/Incoming.java— 4 new constantsmessages/PacketManager.java— 4registerHandlerlineshabbohotel/messenger/MessengerBuddy.java—setCategoryId(int)habbohotel/users/HabboInfo.java—renameMessengerCategory(int, String)messages/incoming/friends/AddFriendCategoryEvent.javamessages/incoming/friends/RenameFriendCategoryEvent.javamessages/incoming/friends/RemoveFriendCategoryEvent.javamessages/incoming/friends/MoveFriendToCategoryEvent.javaRenderer (
Nitro_Render_V3/packages/communication/src/):messages/outgoing/OutgoingHeader.ts— 4 constantsNitroMessages.ts— imports + 4_composers.setmessages/outgoing/friendlist/index.ts— 4 exportsmessages/outgoing/friendlist/AddFriendCategoryComposer.tsmessages/outgoing/friendlist/RenameFriendCategoryComposer.tsmessages/outgoing/friendlist/RemoveFriendCategoryComposer.tsmessages/outgoing/friendlist/MoveFriendToCategoryComposer.tsmessages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.tsClient (
Nitro-V3/src/):api/friends/friendCategory.helpers.ts+.test.tsapi/friends/index.ts— export helperhooks/friends/useFriends.ts— 4 actions + composer importscomponents/friends/views/friends-list/FriendsListView.tsx— chip row + filtercomponents/friends/views/friends-list/FriendsListGroupChipsView.tsx— chip filter rowcomponents/friends/views/friends-list/FriendsCategoryManagerView.tsx— create/rename/delete modalcomponents/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx— assign controlcss/friends/FriendsView.css— chip + manager + assign stylesTask 1: Allocate & verify the 4 category header IDs
Files: none yet (decision + verification only).
The user prefers official Habbo revision IDs for category packets. First check whether the connecting revision shipped friend-category management packets:
Run (renderer):
grep -rin "categor" Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.tsExpected: onlyMESSENGER_*friend headers, no add/rename/remove/move-category constant.If no official category constants are found (expected — the bundled revision's
OutgoingHeader.tshas none), use the custom fallback quartet 4081, 4082, 4083, 4084 and verify they are free on BOTH sides.Run:
Expected: no output from either command (the IDs are free).
If either prints a match, increment the base (try 4085–4088, etc.) and re-run until both commands return nothing. Record the final quartet here before continuing:
All later tasks reference the constant names, so only Tasks 2 and 4 (the constant definitions) carry the raw numbers. If you changed the numbers above, use your values in Tasks 2 and 4.
Task 2: Renderer — 4 outgoing composers + registration + test
Files:
Create:
Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/AddFriendCategoryComposer.tsCreate:
Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/RenameFriendCategoryComposer.tsCreate:
Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/RemoveFriendCategoryComposer.tsCreate:
Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/MoveFriendToCategoryComposer.tsModify:
Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.tsModify:
Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/index.tsModify:
Nitro_Render_V3/packages/communication/src/NitroMessages.tsTest:
Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.tsStep 1: Write the failing test
Create
__tests__/FriendCategoryComposers.test.ts:Run:
cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.tsExpected: FAIL — cannot find module../AddFriendCategoryComposer(files not created yet).AddFriendCategoryComposer.ts:RenameFriendCategoryComposer.ts:RemoveFriendCategoryComposer.ts:MoveFriendToCategoryComposer.ts:Run:
cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.tsExpected: PASS (4 tests).In
OutgoingHeader.ts, next to the other friend headers (afterpublic static FRIEND_LIST_UPDATE = 1419;), add (use your Task 1 numbers):In
messages/outgoing/friendlist/index.ts, add:First find how friendlist composers are imported in
NitroMessages.ts: Run:grep -n "SendMessageComposer" Nitro_Render_V3/packages/communication/src/NitroMessages.tsThis shows both the import line and the_composers.set(...)line.Add the four classes to that same import statement (the one that imports
SendMessageComposer,SetRelationshipStatusComposer, etc.). Then, next tothis._composers.set(OutgoingHeader.SET_RELATIONSHIP_STATUS, SetRelationshipStatusComposer);, add:Run:
cd Nitro_Render_V3 && yarn compile:fast && yarn test --runExpected: compile clean; all tests pass (138 prior + 4 new = 142).Task 3: Emulator — category persistence helpers
Files:
Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/MessengerBuddy.javaArcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.javasetCategoryIdto MessengerBuddyMessengerBuddyalready hasprivate int categoryId = 0;,private int userOne = 0;(the owner's id),this.id(the friend's id), andgetCategoryId(). Mirror the existingsetRelation/run()DB idiom. Add aftergetCategoryId()(around line 141):(
Connection,PreparedStatement,SQLException,Emulator, andLOGGERare already imported in this file.)renameMessengerCategoryto HabboInfoHabboInfoalready hasloadMessengerCategories(),addMessengerCategory(MessengerCategory)(INSERT + sets generated id),deleteMessengerCategory(MessengerCategory)(removes + DELETE viaSqlQueries.update), andgetMessengerCategories(). Add a rename helper afterdeleteMessengerCategory(around line 238), reusing the sameSqlQueries.updateidiom:(
SqlQueries,MessengerCategory, andLOGGERare already imported/available in this file.)Run:
cd Arcturus-Morningstar-Extended/Emulator && mvn -q compileExpected: BUILD SUCCESS (no compile errors).Task 4: Emulator — 4 incoming handlers + registration
Files:
Create:
Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/AddFriendCategoryEvent.javaCreate:
.../friends/RenameFriendCategoryEvent.javaCreate:
.../friends/RemoveFriendCategoryEvent.javaCreate:
.../friends/MoveFriendToCategoryEvent.javaModify:
.../messages/incoming/Incoming.javaModify:
.../messages/PacketManager.javaStep 1: Add the 4 header constants to Incoming.java
Next to the other friend constants (e.g. after
public static final int InviteFriendsEvent = 1276;), add (use your Task 1 numbers — must match the renderer):Caps: ≤ 20 categories/user, name 1–25 chars, case-insensitive de-dupe. Persists via the existing
HabboInfo.addMessengerCategory(which sets the generated id), then re-pushes the category list with the existingMessengerInitComposer.Deleting a group resets its members to category
0, pushing each via the existingUpdateFriendComposer, then re-pushes the (now shorter) category list.categoryId == 0means "uncategorized"; any other id must be an existing category. Persists viaMessengerBuddy.setCategoryIdand pushes the updated friend viaUpdateFriendComposer.In
PacketManager.java, insideregisterFriends(), add:Then add the four imports near the other
com.eu.habbo.messages.incoming.friends.*imports at the top of the file:(If
PacketManager.javaalready uses a wildcardimport com.eu.habbo.messages.incoming.friends.*;, skip the explicit imports — check withgrep -n "incoming.friends" PacketManager.javafirst.)Run:
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTestsExpected: BUILD SUCCESS; jar produced undertarget/. A failure here most likely means a duplicate header (theregisterHandlerguard throwsHeader already registered) — return to Task 1 and pick different free IDs.Task 5: Client — pure group-filter helper (TDD)
Files:
Create:
Nitro-V3/src/api/friends/friendCategory.helpers.tsTest:
Nitro-V3/src/api/friends/friendCategory.helpers.test.tsModify:
Nitro-V3/src/api/friends/index.tsStep 1: Write the failing test
friendCategory.helpers.test.ts:Run:
cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.tsExpected: FAIL — cannot resolve./friendCategory.helpers.friendCategory.helpers.ts:Run:
cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.tsExpected: PASS (6 cases).In
src/api/friends/index.ts, add:Task 6: Client — category CRUD + assign actions in the friends store
Files:
Nitro-V3/src/hooks/friends/useFriends.tsThe store re-pushes authoritative state through the server (the existing
MessengerInitEventhandler updatessettings.categories;FriendListUpdateEventupdates each friend'scategoryId), so these actions are thin send-wrappers — no optimistic local mutation.In the top import from
@nitrots/nitro-rendererinuseFriends.ts, addAddFriendCategoryComposer,MoveFriendToCategoryComposer,RemoveFriendCategoryComposer,RenameFriendCategoryComposerto the named-import list.useFriendsStoreAdd alongside
followFriend/updateRelationship(around line 42), before thereturn:In
useFriendsStore'sreturn { ... }add:addCategory, renameCategory, removeCategory, moveFriendToCategory.useFriendsActionsIn the
useFriendsActionsdestructure-from-useBetweenAND itsreturn, add the four names so consumers can pull them:(The deprecated
useFriendsshim returns the whole store, so it picks these up automatically.)Run:
cd Nitro-V3 && yarn typecheck && yarn test --runExpected: typecheck clean; all tests pass (no regressions).Task 7: Client — group chip-filter row
Files:
Create:
Nitro-V3/src/components/friends/views/friends-list/FriendsListGroupChipsView.tsxModify:
Nitro-V3/src/components/friends/views/friends-list/FriendsListView.tsxStep 1: Create the chip row component
It renders an "All" chip plus one chip per category (with member count), and a gear button to open the manager (wired in Task 8). It is a controlled component: parent owns
selectedCategoryId.(If
Flexis not exported from../../../../common, check the import other friends views use —FriendsListView.tsxalready importsFlex,Buttonfrom../../../../common; match that path.)In
FriendsListView.tsx:(
useStatemay already be imported — merge into the existing react import.FriendsCategoryManagerViewis created in Task 8; this import is fine to add now and will resolve after Task 8.)settingsfrom the friends state hook used in this component (it already destructures fromuseFriendsState/useFriends). Ensuresettings(andfriendsfor the unfiltered counts) are in scope:(Adjust to match the existing hook call in this file — keep whatever names it already destructures and add
settings+friends.)useStatecalls:<NitroCardContentView ...>(before the<NitroCardAccordionView>):Replace every reference to
onlineFriends/offlineFriendsthat feeds the rendered list and counts with the filtered versions (six edits):(${ filteredOnlineFriends.length })select_alltoolbar: map/every overfilteredOnlineFriends<FriendsListGroupView list={ filteredOnlineFriends } ... />(${ filteredOfflineFriends.length })select_alltoolbar: map/every overfilteredOfflineFriends<FriendsListGroupView list={ filteredOfflineFriends } ... />(Leave the unfiltered
friendsfor the chip counts andonlineFriends/offlineFriendsreferences that are NOT about the rendered sections, if any.)Render the manager modal at the end of the returned fragment (next to the existing
showRoomInvite/showRemoveFriendsConfirmationblocks):Run:
cd Nitro-V3 && yarn typecheckExpected: clean (a TS2307 forFriendsCategoryManagerViewis acceptable here only until Task 8 lands; if you implement Task 8 next there should be no errors). If you want a green checkpoint now, temporarily comment the manager import + render block, then restore in Task 8.Task 8: Client — category manager (create / rename / delete)
Files:
Create:
Nitro-V3/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsxStep 1: Create the manager modal
A small
NitroCardViewwith an add-input and a list of categories, each with inline rename + delete. UsesuseFriendsActions.Run:
cd Nitro-V3 && yarn typecheck && yarn test --runExpected: clean; tests green. (If you commented the manager wiring in Task 7 Step 3, restore it now.)Task 9: Client — per-friend "assign to group" control
Files:
Modify:
Nitro-V3/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsxStep 1: Add categories + action to the component
At the top of the component body, pull categories from state and the move action from actions:
(Match the existing relative depth — this file is one level deeper than
FriendsListView.tsx, so it's../../../../../hooks; confirm with the file's existing imports.)Inside the component:
(
useStateis likely already imported; if not, add it.)In the
friends-list-actionsdiv (currently the follow / chat / relationship icons), add — only when the friend is a real user (friend.id > 0) and at least one category exists — a group icon that toggles a small menu:(Reuse an existing spritesheet class for
icon-groupif one fits, or fall back to a text glyph. The "uncategorized" entry usesfriend.categoryId === 0.)Run:
cd Nitro-V3 && yarn typecheck && yarn test --runExpected: clean; tests green.Task 10: Client — styles for chips, manager, assign menu
Files:
Modify:
Nitro-V3/src/css/friends/FriendsView.cssStep 1: Append styles
Match the existing palette in this file (grays
#f3f3ef/#e6e6e6, accent blue#bfe7f6,#111text). Append:Run:
cd Nitro-V3 && yarn typecheckExpected: clean (CSS isn't type-checked, but confirm nothing else broke).Task 11: Integration verification (two live sessions)
Files: none (manual verification + final checks). Fix-up commits only if something fails.
The
messenger_categoriesandmessenger_friendships.categoryschema already exist (verified). Confirm against the running DB:(Per house rules, if a destructive statement is ever needed, hand the user a one-liner to run under
E:\laragon\bin\mysql\...— but nothing destructive is required here.)Step 2: Run the new emulator jar + client dev server
Emulator: run the jar built in Task 4 (
java -jar Arcturus-Morningstar-Extended/Emulator/target/Habbo-*-jar-with-dependencies.jar).Client:
cd Nitro-V3 && yarn start(Vite picks up the renderer source live — no renderer build needed).Step 3: Manual test matrix (log in, open Friends)
Verify each:
MessengerInit).maxLength/server.Run:
Expected: renderer tests green (142), client tests green (existing + 6 new helper cases), client typecheck + eslint clean, emulator BUILD SUCCESS.
Notes for the next phase
FriendChatMessageComposerreplay path — no new packets — and themessenger_offlinetable that already exists.NewConsoleMessageEvent/NewConsoleMessageParser) which this phase did not need.--base dev/Devconvention when the user asks.