From c25cb2a9b6f14442ba3643c7dcbd694ac6784e89 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 02:02:37 +0200 Subject: [PATCH 01/33] fix(trading): abort item exchange when persistence fails RoomTrade previously caught SQLException during ownership updates but continued into the in-memory inventory and credit transfer path. That could desynchronize or duplicate trade results if the database batch failed while the live session still completed the exchange. Keep item owner mutations after the successful batch, return offered items on failed completion, and add a contract test that prevents SQL failures from falling through to the transfer path. --- .../eu/habbo/habbohotel/rooms/RoomTrade.java | 31 +++++++++++++-- .../rooms/RoomTradeSafetyContractTest.java | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeSafetyContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java index 38d2b66f..24ca36ea 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java @@ -58,7 +58,7 @@ public class RoomTrade { public synchronized void offerItem(Habbo habbo, HabboItem item) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); - if (user.getItems().contains(item)) + if (user == null || item == null || user.getItems().contains(item)) return; habbo.getInventory().getItemsComponent().removeHabboItem(item); @@ -71,6 +71,9 @@ public class RoomTrade { public synchronized void offerMultipleItems(Habbo habbo, THashSet items) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); + if (user == null || items == null) + return; + for (HabboItem item : items) { if (!user.getItems().contains(item)) { habbo.getInventory().getItemsComponent().removeHabboItem(item); @@ -85,7 +88,7 @@ public class RoomTrade { public synchronized void removeItem(Habbo habbo, HabboItem item) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); - if (!user.getItems().contains(item)) + if (user == null || item == null || !user.getItems().contains(item)) return; habbo.getInventory().getItemsComponent().addItem(item); @@ -98,6 +101,9 @@ public class RoomTrade { public synchronized void accept(Habbo habbo, boolean value) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); + if (user == null) + return; + user.setAccepted(value); this.sendMessageToUsers(new TradeAcceptedComposer(user)); @@ -120,6 +126,9 @@ public class RoomTrade { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); + if (user == null) + return; + user.confirm(); this.sendMessageToUsers(new TradeAcceptedComposer(user)); @@ -134,6 +143,12 @@ public class RoomTrade { if (this.tradeItems()) { this.closeWindow(); this.sendMessageToUsers(new TradeCompleteComposer()); + } else { + this.returnItems(); + for (RoomTradeUser roomTradeUser : this.users) { + roomTradeUser.clearItems(); + } + this.closeWindow(); } this.room.stopTrade(this); @@ -188,7 +203,6 @@ public class RoomTrade { try (PreparedStatement statement = connection.prepareStatement("UPDATE items SET user_id = ? WHERE id = ? LIMIT 1")) { try (PreparedStatement stmt = connection.prepareStatement("INSERT INTO room_trade_log_items (id, item_id, user_id) VALUES (?, ?, ?)")) { for (HabboItem item : userOne.getItems()) { - item.setUserId(userTwoId); statement.setInt(1, userTwoId); statement.setInt(2, item.getId()); statement.addBatch(); @@ -202,7 +216,6 @@ public class RoomTrade { } for (HabboItem item : userTwo.getItems()) { - item.setUserId(userOneId); statement.setInt(1, userOneId); statement.setInt(2, item.getId()); statement.addBatch(); @@ -224,6 +237,16 @@ public class RoomTrade { } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); + this.sendMessageToUsers(new TradeClosedComposer(userOne.getHabbo().getRoomUnit().getId(), TradeClosedComposer.ITEMS_NOT_FOUND)); + return false; + } + + for (HabboItem item : userOne.getItems()) { + item.setUserId(userTwo.getHabbo().getHabboInfo().getId()); + } + + for (HabboItem item : userTwo.getItems()) { + item.setUserId(userOne.getHabbo().getHabboInfo().getId()); } THashSet itemsUserOne = new THashSet<>(userOne.getItems()); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeSafetyContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeSafetyContractTest.java new file mode 100644 index 00000000..d1614e84 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeSafetyContractTest.java @@ -0,0 +1,38 @@ +package com.eu.habbo.habbohotel.rooms; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RoomTradeSafetyContractTest { + private static String roomTradeSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java")); + } + + @Test + void sqlFailureStopsBeforeInventoryTransfer() throws Exception { + String source = roomTradeSource(); + int catchIndex = source.indexOf("catch (SQLException e)"); + int inventoryTransferIndex = source.indexOf("THashSet itemsUserOne"); + + assertTrue(catchIndex > -1, "RoomTrade must handle SQL failures explicitly"); + assertTrue(inventoryTransferIndex > catchIndex, "Inventory transfer should happen after SQL ownership updates"); + assertTrue(source.substring(catchIndex, inventoryTransferIndex).contains("return false"), + "SQL failures must abort the trade before in-memory inventory/credit transfer"); + } + + @Test + void itemOwnersChangeOnlyAfterDatabaseBatchSucceeds() throws Exception { + String source = roomTradeSource(); + int firstOwnerMutation = source.indexOf("item.setUserId("); + int batchExecution = source.indexOf("statement.executeBatch();"); + + assertTrue(firstOwnerMutation > -1, "RoomTrade should update in-memory item owners after commit"); + assertTrue(batchExecution > -1, "RoomTrade should persist item owner changes with a batch update"); + assertTrue(firstOwnerMutation > batchExecution, + "In-memory item owners must not change until the database batch has succeeded"); + } +} From 0f153716760b22a7f7f43fc7bdb2672d0eec538f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 02:07:01 +0200 Subject: [PATCH 02/33] fix(marketplace): only pay out claimed offers after detach MarketPlace.getCredits previously removed sold offers from memory and granted credits before knowing whether marketplace_items.user_id had been detached in the database. If that update failed, the same sold offer could be loaded as claimable again later. Make removeUser report success, keep the offer claimable on failure, and only grant credits after the database detach succeeds. --- .../catalog/marketplace/MarketPlace.java | 16 ++++---- .../MarketPlaceCreditClaimContractTest.java | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceCreditClaimContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java index e7952892..b0646439 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java @@ -398,11 +398,12 @@ public class MarketPlace { synchronized (client.getHabbo().getInventory()) { for (MarketPlaceOffer offer : offers) { if (offer.getState().equals(MarketPlaceState.SOLD)) { - client.getHabbo().getInventory().removeMarketplaceOffer(offer); - credits += offer.getPrice(); - removeUser(offer); - offer.needsUpdate(true); - Emulator.getThreading().run(offer); + if (removeUser(offer)) { + client.getHabbo().getInventory().removeMarketplaceOffer(offer); + credits += offer.getPrice(); + offer.needsUpdate(true); + Emulator.getThreading().run(offer); + } } } } @@ -416,13 +417,14 @@ public class MarketPlace { } } - private static void removeUser(MarketPlaceOffer offer) { + private static boolean removeUser(MarketPlaceOffer offer) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE marketplace_items SET user_id = ? WHERE id = ?")) { statement.setInt(1, -1); statement.setInt(2, offer.getOfferId()); - statement.execute(); + return statement.executeUpdate() > 0; } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); + return false; } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceCreditClaimContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceCreditClaimContractTest.java new file mode 100644 index 00000000..9c0faa49 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceCreditClaimContractTest.java @@ -0,0 +1,38 @@ +package com.eu.habbo.habbohotel.catalog.marketplace; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MarketPlaceCreditClaimContractTest { + private static String marketPlaceSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java")); + } + + @Test + void soldOfferIsDetachedBeforeCreditsAreGranted() throws Exception { + String source = marketPlaceSource(); + int getCreditsStart = source.indexOf("public static void getCredits"); + int removeUserCall = source.indexOf("removeUser(offer)", getCreditsStart); + int creditAccumulator = source.indexOf("credits += offer.getPrice()", getCreditsStart); + int inventoryRemoval = source.indexOf("removeMarketplaceOffer(offer)", getCreditsStart); + + assertTrue(getCreditsStart > -1, "MarketPlace.getCredits must exist"); + assertTrue(removeUserCall > -1, "Sold marketplace offers must be detached in the database"); + assertTrue(removeUserCall < creditAccumulator, + "Credits must not be granted until the sold offer is detached from the seller in the database"); + assertTrue(removeUserCall < inventoryRemoval, + "The in-memory sold offer should remain claimable if the database detach fails"); + } + + @Test + void detachFailureIsObservableByCaller() throws Exception { + String source = marketPlaceSource(); + + assertTrue(source.contains("private static boolean removeUser"), + "removeUser must report whether the marketplace ownership update succeeded"); + } +} From 216078f62c00c8d369c14ede61f8e26f76d949c1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:14:04 +0200 Subject: [PATCH 03/33] fix(messages): silence duplicate packet aliases PacketNames reflects public static final packet constants and warns when two names share the same header. RequestCatalogIndexEvent is a legacy alias for the active Builders Club catalog index header, and InClientLinkComposer shares the NUX link payload/header. Keep those aliases available to existing code while removing them from the reflected packet-name set, and add a contract test so future public final packet names stay unique. --- .../eu/habbo/messages/incoming/Incoming.java | 2 +- .../eu/habbo/messages/outgoing/Outgoing.java | 2 +- .../messages/PacketNamesContractTest.java | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/PacketNamesContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 43ab4d44..3a0bdf1a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -83,7 +83,7 @@ public class Incoming { public static final int GuildAcceptMembershipEvent = 3386; public static final int RequestRecylerLogicEvent = 398; public static final int RequestGuildJoinEvent = 998; - public static final int RequestCatalogIndexEvent = 2529; + public static int RequestCatalogIndexEvent = 2529; public static final int BuildersClubQueryFurniCountEvent = 2529; public static final int BuildersClubPlaceRoomItemEvent = 1051; public static final int BuildersClubPlaceWallItemEvent = 462; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index 4db4519d..c52ef526 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -514,7 +514,7 @@ public class Outgoing { public final static int WiredOpenComposer = 1830; public final static int UnknownCatalogPageOfferComposer = 1889; public final static int NuxAlertComposer = 2023; - public final static int InClientLinkComposer = 2023; + public static int InClientLinkComposer = NuxAlertComposer; public final static int HotelViewExpiringCatalogPageCommposer = 2515; public final static int UnknownHabboWayQuizComposer = 2772; public final static int PetLevelUpdatedComposer = 2824; diff --git a/Emulator/src/test/java/com/eu/habbo/messages/PacketNamesContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/PacketNamesContractTest.java new file mode 100644 index 00000000..9c4b02c3 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/PacketNamesContractTest.java @@ -0,0 +1,51 @@ +package com.eu.habbo.messages; + +import com.eu.habbo.messages.incoming.Incoming; +import com.eu.habbo.messages.outgoing.Outgoing; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PacketNamesContractTest { + @Test + void incomingPacketNameIdsAreUnique() throws Exception { + assertPublicFinalPacketIdsAreUnique(Incoming.class); + } + + @Test + void outgoingPacketNameIdsAreUnique() throws Exception { + assertPublicFinalPacketIdsAreUnique(Outgoing.class); + } + + private static void assertPublicFinalPacketIdsAreUnique(Class packetClass) throws Exception { + Map seen = new HashMap<>(); + Map duplicates = new HashMap<>(); + + for (Field field : packetClass.getFields()) { + int modifiers = field.getModifiers(); + if (!Modifier.isPublic(modifiers) + || !Modifier.isStatic(modifiers) + || !Modifier.isFinal(modifiers) + || field.getType() != int.class) { + continue; + } + + int packetId = field.getInt(null); + if (packetId <= 0) { + continue; + } + + String previous = seen.putIfAbsent(packetId, field.getName()); + if (previous != null) { + duplicates.put(packetId, previous + " / " + field.getName()); + } + } + + assertTrue(duplicates.isEmpty(), packetClass.getSimpleName() + " has duplicate packet IDs: " + duplicates); + } +} From ede7eb8284b71bf9eda986dec61cc04c0f149be6 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:19:17 +0200 Subject: [PATCH 04/33] style(startup): tidy console banner logs Shorten the infostand background startup message into a compact asset summary and print the project/version/build details as a single ASCII startup card instead of several timestamped log lines. Add a small contract test for the compact infostand summary format. --- .../src/main/java/com/eu/habbo/Emulator.java | 20 ++++++++++++------- .../infostand/InfostandBackgroundManager.java | 10 ++++++++-- .../InfostandBackgroundManagerTest.java | 14 +++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index de1a4e86..b6ee12a3 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -154,13 +154,7 @@ public final class Emulator { Emulator.config.register("camera.render.delay", "5"); Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); - System.out.println(); - LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, "); - System.out.println(); - LOGGER.info("This project is for educational purposes only. This Emulator is an open-source fork of Arcturus created by TheGeneral."); - LOGGER.info("Version: {}", version); - LOGGER.info("Build: {}", build); - LOGGER.info("Build Timestamp: {} [{}]", formatBuildTimestamp(buildTimestamp, hotelTimezoneId), hotelTimezoneId); + System.out.println(startupCard(hotelTimezoneId)); Emulator.texts.register("camera.permission", "You don't have permission to use the camera!"); Emulator.texts.register("camera.wait", "Please wait %seconds% seconds before making another picture."); Emulator.texts.register("camera.error.creation", "Failed to create your picture. *sadpanda*"); @@ -310,6 +304,18 @@ public final class Emulator { return -1L; } + static String startupCard(String hotelTimezoneId) { + return "\n" + + "+----------------------------------------------------------------+\n" + + "| Arcturus Morningstar Extended |\n" + + "| Source : github.com/duckietm/Arcturus-Morningstar-Extended |\n" + + "| Scope : Educational open-source fork by TheGeneral |\n" + + "| Version: " + version + "\n" + + "| Build : " + build + "\n" + + "| Time : " + formatBuildTimestamp(buildTimestamp, hotelTimezoneId) + " [" + hotelTimezoneId + "]\n" + + "+----------------------------------------------------------------+\n"; + } + private static String formatBuildTimestamp(long buildTimestamp, String timezoneId) { if (buildTimestamp <= 0) { return "UNKNOWN"; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java index 8217bbfe..125635a9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java @@ -90,17 +90,23 @@ public class InfostandBackgroundManager { this.enforce = loaded > 0; if (this.enforce) { - LOGGER.info("InfostandBackgroundManager -> Loaded {} backgrounds, {} stands, {} overlays, {} cards, {} borders from infostand_backgrounds.", + LOGGER.info(summary( this.entries.get(Category.BACKGROUND).size(), this.entries.get(Category.STAND).size(), this.entries.get(Category.OVERLAY).size(), this.entries.get(Category.CARD).size(), - this.entries.get(Category.BORDER).size()); + this.entries.get(Category.BORDER).size())); } else { LOGGER.info("InfostandBackgroundManager -> infostand_backgrounds is empty, server-side validation disabled (only range clamp will apply)."); } } + static String summary(int backgrounds, int stands, int overlays, int cards, int borders) { + int total = backgrounds + stands + overlays + cards + borders; + return String.format("Infostand Background Manager -> Loaded! (%d assets: %d bg, %d stands, %d overlays, %d cards, %d borders)", + total, backgrounds, stands, overlays, cards, borders); + } + public boolean canUse(Habbo habbo, Category category, int id) { if (id == 0) return true; if (!this.enforce) return true; diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java new file mode 100644 index 00000000..93db4bf1 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java @@ -0,0 +1,14 @@ +package com.eu.habbo.habbohotel.users.infostand; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class InfostandBackgroundManagerTest { + @Test + void summaryKeepsStartupLogCompact() { + assertEquals( + "Infostand Background Manager -> Loaded! (260 assets: 188 bg, 22 stands, 9 overlays, 16 cards, 25 borders)", + InfostandBackgroundManager.summary(188, 22, 9, 16, 25)); + } +} From 16d89cdb31534f50a893bd67f847a4dfd5890170 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:29:53 +0200 Subject: [PATCH 05/33] style(startup): customize emulator console banner Add a clean ASCII startup banner for the emulator CMD window and use it instead of the legacy wide block logo. The new banner stays ASCII-only for Windows console compatibility and keeps the Morningstar identity visible before the startup logs. --- Emulator/src/main/java/com/eu/habbo/Emulator.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index b6ee12a3..87d41a06 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -65,6 +65,16 @@ public final class Emulator { "██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝███████║ ██║ ██║ ██║██║ ██║\n" + "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝\n" + "Still Rocking in 2026.\n"; + private static final String consoleLogo = + "\n" + + "============================================================\n" + + " __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" + + " | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" + + " | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" + + " | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" + + " |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" + + " Arcturus Morningstar Extended - 2026\n" + + "============================================================\n"; public static String build = ""; public static long buildTimestamp = -1L; @@ -119,7 +129,7 @@ public final class Emulator { ConsoleCommand.load(); Emulator.logging = new Logging(); - System.out.println(logo); + System.out.println(consoleLogo); long startTime = System.nanoTime(); From ea552589792d31d48393e74f073f660877bc1a39 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:34:29 +0200 Subject: [PATCH 06/33] style(startup): use universal console splash Replace the temporary ASCII-art banner with a structured startup splash that uses plain ASCII, aligned fields, and no ANSI or terminal-specific features. This keeps the emulator startup readable across CMD, PowerShell, Linux terminals, Docker logs, CI output, and copied log files. Add a contract test to keep the splash universal. --- .../src/main/java/com/eu/habbo/Emulator.java | 34 ++++++++++++------- .../eu/habbo/EmulatorStartupConsoleTest.java | 18 ++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 87d41a06..38fb74e9 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -65,17 +65,6 @@ public final class Emulator { "██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝███████║ ██║ ██║ ██║██║ ██║\n" + "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝\n" + "Still Rocking in 2026.\n"; - private static final String consoleLogo = - "\n" + - "============================================================\n" + - " __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" + - " | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" + - " | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" + - " | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" + - " |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" + - " Arcturus Morningstar Extended - 2026\n" + - "============================================================\n"; - public static String build = ""; public static long buildTimestamp = -1L; @@ -129,7 +118,7 @@ public final class Emulator { ConsoleCommand.load(); Emulator.logging = new Logging(); - System.out.println(consoleLogo); + System.out.println(startupHero()); long startTime = System.nanoTime(); @@ -326,6 +315,27 @@ public final class Emulator { "+----------------------------------------------------------------+\n"; } + static String startupHero() { + return "\n" + + "+------------------------------------------------------------------------------+\n" + + "| MORNINGSTAR EXTENDED |\n" + + "| Arcturus game server runtime |\n" + + "+------------------------------------------------------------------------------+\n" + + "| Version : " + fit(version, 63) + " |\n" + + "| Build : " + fit(build.isBlank() ? "UNKNOWN" : build, 63) + " |\n" + + "| Runtime : " + fit("Java " + System.getProperty("java.version", "unknown") + " / universal console output", 63) + " |\n" + + "+------------------------------------------------------------------------------+\n"; + } + + private static String fit(String value, int width) { + String safe = value == null ? "" : value; + if (safe.length() > width) { + return safe.substring(0, Math.max(0, width - 3)) + "..."; + } + + return String.format("%-" + width + "s", safe); + } + private static String formatBuildTimestamp(long buildTimestamp, String timezoneId) { if (buildTimestamp <= 0) { return "UNKNOWN"; diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java new file mode 100644 index 00000000..629c2390 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -0,0 +1,18 @@ +package com.eu.habbo; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EmulatorStartupConsoleTest { + @Test + void startupHeroUsesUniversalAsciiLayout() { + String hero = Emulator.startupHero(); + + assertTrue(hero.contains("MORNINGSTAR EXTENDED")); + assertTrue(hero.contains("Version")); + assertTrue(hero.contains("Build")); + assertFalse(hero.contains("\u001B["), "startup hero must not require ANSI support"); + } +} From 9edb984f56764bd17da8a7e99d6df51e62bea2b3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:43:40 +0200 Subject: [PATCH 07/33] style(startup): improve universal console layout Keep the Morningstar ASCII logo while using a structured plain-text startup card that works in CMD, Windows Terminal, and other consoles without ANSI support. Compact the Logback console pattern to use simple class names, clean separators, and a wider message column so startup logs do not wrap as aggressively. Simplify Infostand startup output to a one-line asset count while preserving category breakdown at DEBUG level. Also normalize generic server start/stop messages so Game Server and RCON Server are labeled correctly instead of being glued to host:port output. --- .../src/main/java/com/eu/habbo/Emulator.java | 6 ++++++ .../infostand/InfostandBackgroundManager.java | 9 +++++++-- .../java/com/eu/habbo/networking/Server.java | 6 +++--- Emulator/src/main/resources/logback.xml | 4 ++-- .../eu/habbo/ConsoleLogbackLayoutTest.java | 20 +++++++++++++++++++ .../eu/habbo/EmulatorStartupConsoleTest.java | 1 + .../InfostandBackgroundManagerTest.java | 2 +- 7 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 38fb74e9..fe88ec9a 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -317,6 +317,12 @@ public final class Emulator { static String startupHero() { return "\n" + + " __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" + + " | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" + + " | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" + + " | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" + + " |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" + + "\n" + "+------------------------------------------------------------------------------+\n" + "| MORNINGSTAR EXTENDED |\n" + "| Arcturus game server runtime |\n" + diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java index 125635a9..3e2805c8 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java @@ -96,6 +96,12 @@ public class InfostandBackgroundManager { this.entries.get(Category.OVERLAY).size(), this.entries.get(Category.CARD).size(), this.entries.get(Category.BORDER).size())); + LOGGER.debug("Infostand Background Manager assets: {} bg, {} stands, {} overlays, {} cards, {} borders", + this.entries.get(Category.BACKGROUND).size(), + this.entries.get(Category.STAND).size(), + this.entries.get(Category.OVERLAY).size(), + this.entries.get(Category.CARD).size(), + this.entries.get(Category.BORDER).size()); } else { LOGGER.info("InfostandBackgroundManager -> infostand_backgrounds is empty, server-side validation disabled (only range clamp will apply)."); } @@ -103,8 +109,7 @@ public class InfostandBackgroundManager { static String summary(int backgrounds, int stands, int overlays, int cards, int borders) { int total = backgrounds + stands + overlays + cards + borders; - return String.format("Infostand Background Manager -> Loaded! (%d assets: %d bg, %d stands, %d overlays, %d cards, %d borders)", - total, backgrounds, stands, overlays, cards, borders); + return String.format("Infostand Background Manager -> Loaded! (%d assets)", total); } public boolean canUse(Habbo habbo, Category category, int id) { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/Server.java b/Emulator/src/main/java/com/eu/habbo/networking/Server.java index 61258f2d..ec24c314 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/Server.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/Server.java @@ -85,10 +85,10 @@ public abstract class Server { } if (!channelFuture.isSuccess()) { - LOGGER.info("Failed to connect to the host ({}:{})@{}", this.host, this.port, this.name); + LOGGER.info("Failed to start {} on {}:{}", this.name, this.host, this.port); System.exit(0); } else { - LOGGER.info("Started GameServer on {}:{}@{}", this.host, this.port, this.name); + LOGGER.info("Started {} on {}:{}", this.name, this.host, this.port); } } @@ -100,7 +100,7 @@ public abstract class Server { } catch(InterruptedException e) { LOGGER.error("Exception during {} shutdown... HARD STOP", this.name, e); } - LOGGER.info("GameServer Stopped!"); + LOGGER.info("Stopped {}", this.name); } public ServerBootstrap getServerBootstrap() { diff --git a/Emulator/src/main/resources/logback.xml b/Emulator/src/main/resources/logback.xml index f4a62d3f..5a972548 100644 --- a/Emulator/src/main/resources/logback.xml +++ b/Emulator/src/main/resources/logback.xml @@ -2,7 +2,7 @@ - %d{HH:mm:ss.SSS} [%-14thread] %-5level %-36logger{36} - %msg%n + %d{HH:mm:ss.SSS} %-5level [%-12thread] %-22logger{0} | %msg%n @@ -65,4 +65,4 @@ - \ No newline at end of file + diff --git a/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java b/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java new file mode 100644 index 00000000..0f044868 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java @@ -0,0 +1,20 @@ +package com.eu.habbo; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConsoleLogbackLayoutTest { + @Test + void consolePatternKeepsStartupMessagesReadable() throws Exception { + String logback = Files.readString(Path.of("src/main/resources/logback.xml")); + + assertTrue(logback.contains("%-22logger{0}"), "console should show compact class names"); + assertTrue(logback.contains("| %msg%n"), "console should leave a clear message column"); + assertFalse(logback.contains("%-36logger{36}"), "wide package loggers waste console space"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java index 629c2390..4e86f21e 100644 --- a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -10,6 +10,7 @@ class EmulatorStartupConsoleTest { void startupHeroUsesUniversalAsciiLayout() { String hero = Emulator.startupHero(); + assertTrue(hero.contains("__ __ ___ ____")); assertTrue(hero.contains("MORNINGSTAR EXTENDED")); assertTrue(hero.contains("Version")); assertTrue(hero.contains("Build")); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java index 93db4bf1..531ed49e 100644 --- a/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManagerTest.java @@ -8,7 +8,7 @@ class InfostandBackgroundManagerTest { @Test void summaryKeepsStartupLogCompact() { assertEquals( - "Infostand Background Manager -> Loaded! (260 assets: 188 bg, 22 stands, 9 overlays, 16 cards, 25 borders)", + "Infostand Background Manager -> Loaded! (260 assets)", InfostandBackgroundManager.summary(188, 22, 9, 16, 25)); } } From 98e366dd0752fc3172876a09ee805fb168809dcb Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:50:17 +0200 Subject: [PATCH 08/33] style(startup): add adaptive console colors Add an auto-detected styled startup splash for terminals that support ANSI colors, including Windows Terminal, ANSICON, ConEmu ANSI, and common TERM-based consoles. Keep the default and redirected-output path plain text so legacy CMD, logs, and service wrappers remain readable. The style can also be forced with -Dhabbo.console.style=ansi or disabled with -Dhabbo.console.style=plain. Cover the styled splash, Windows Terminal detection, non-interactive fallback, and forced plain mode with startup console tests. --- .../src/main/java/com/eu/habbo/Emulator.java | 67 ++++++++++++++++++- .../eu/habbo/EmulatorStartupConsoleTest.java | 39 +++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index fe88ec9a..765f676a 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -38,6 +38,12 @@ public final class Emulator { private static final Logger LOGGER = LoggerFactory.getLogger(Emulator.class); private static final String OS_NAME = (System.getProperty("os.name") != null ? System.getProperty("os.name") : "Unknown"); private static final String CLASS_PATH = (System.getProperty("java.class.path") != null ? System.getProperty("java.class.path") : "Unknown"); + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_BOLD = "\u001B[1m"; + private static final String ANSI_CYAN = "\u001B[36m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_YELLOW = "\u001B[33m"; + private static final String ANSI_DIM = "\u001B[2m"; // Fallback version, only used when running outside a packaged jar (e.g. from // the IDE). In production the version comes from the jar manifest below. @@ -118,7 +124,11 @@ public final class Emulator { ConsoleCommand.load(); Emulator.logging = new Logging(); - System.out.println(startupHero()); + System.out.println(startupHero(shouldStyleConsole( + System.getenv(), + System.console() != null, + OS_NAME, + System.getProperty("habbo.console.style", "auto")))); long startTime = System.nanoTime(); @@ -316,6 +326,30 @@ public final class Emulator { } static String startupHero() { + return startupHero(false); + } + + static String startupHero(boolean styled) { + if (styled) { + return "\n" + + ANSI_CYAN + + " __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" + + " | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" + + " | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" + + " | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" + + " |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" + + ANSI_RESET + + "\n" + + ANSI_DIM + "+------------------------------------------------------------------------------+" + ANSI_RESET + "\n" + + "| " + ANSI_BOLD + ANSI_GREEN + "[OK] MORNINGSTAR EXTENDED" + ANSI_RESET + fit("", 50) + " |\n" + + "| " + ANSI_DIM + "Arcturus game server runtime" + ANSI_RESET + fit("", 48) + " |\n" + + ANSI_DIM + "+------------------------------------------------------------------------------+" + ANSI_RESET + "\n" + + "| " + ANSI_YELLOW + "[VER]" + ANSI_RESET + " Version : " + fit(version, 57) + " |\n" + + "| " + ANSI_YELLOW + "[BLD]" + ANSI_RESET + " Build : " + fit(build.isBlank() ? "UNKNOWN" : build, 57) + " |\n" + + "| " + ANSI_YELLOW + "[JVM]" + ANSI_RESET + " Runtime : " + fit("Java " + System.getProperty("java.version", "unknown") + " / styled console output", 57) + " |\n" + + ANSI_DIM + "+------------------------------------------------------------------------------+" + ANSI_RESET + "\n"; + } + return "\n" + " __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" + " | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" + @@ -333,6 +367,37 @@ public final class Emulator { "+------------------------------------------------------------------------------+\n"; } + static boolean shouldStyleConsole(Map environment, boolean interactiveConsole, String osName, String styleProperty) { + String style = styleProperty == null ? "auto" : styleProperty.trim().toLowerCase(Locale.ROOT); + if (style.equals("ansi") || style.equals("color") || style.equals("colours") || style.equals("colors")) { + return true; + } + if (style.equals("plain") || style.equals("none") || style.equals("false") || style.equals("off")) { + return false; + } + if (!interactiveConsole) { + return false; + } + + Map env = environment == null ? Collections.emptyMap() : environment; + if (env.containsKey("NO_COLOR")) { + return false; + } + if (env.containsKey("WT_SESSION") || env.containsKey("ANSICON") || "ON".equalsIgnoreCase(env.get("ConEmuANSI"))) { + return true; + } + + String term = env.getOrDefault("TERM", ""); + if (term.equalsIgnoreCase("dumb")) { + return false; + } + if (!term.isBlank() && (term.contains("xterm") || term.contains("ansi") || term.contains("screen") || term.contains("tmux"))) { + return true; + } + + return osName == null || !osName.toLowerCase(Locale.ROOT).startsWith("windows"); + } + private static String fit(String value, int width) { String safe = value == null ? "" : value; if (safe.length() > width) { diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java index 4e86f21e..3f61b09b 100644 --- a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -2,6 +2,8 @@ package com.eu.habbo; import org.junit.jupiter.api.Test; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -16,4 +18,41 @@ class EmulatorStartupConsoleTest { assertTrue(hero.contains("Build")); assertFalse(hero.contains("\u001B["), "startup hero must not require ANSI support"); } + + @Test + void startupHeroCanRenderStyledLayoutWhenAnsiIsAvailable() { + String hero = Emulator.startupHero(true); + + assertTrue(hero.contains("\u001B["), "styled hero should include ANSI colors"); + assertTrue(hero.contains("[OK] MORNINGSTAR EXTENDED")); + assertTrue(hero.contains("[JVM]")); + assertTrue(hero.endsWith("\u001B[0m\n"), "styled hero should reset terminal attributes"); + } + + @Test + void consoleStyleAutoDetectsWindowsTerminal() { + assertTrue(Emulator.shouldStyleConsole( + Map.of("WT_SESSION", "abc123"), + true, + "Windows 11", + "auto")); + } + + @Test + void consoleStyleFallsBackWhenOutputIsNotInteractive() { + assertFalse(Emulator.shouldStyleConsole( + Map.of("WT_SESSION", "abc123"), + false, + "Windows 11", + "auto")); + } + + @Test + void consoleStyleCanBeForcedOff() { + assertFalse(Emulator.shouldStyleConsole( + Map.of("WT_SESSION", "abc123"), + true, + "Windows 11", + "plain")); + } } From a8e0534634548b5563f44f636f2448f321608ac0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:01:08 +0200 Subject: [PATCH 09/33] style(logging): colorize adaptive console logs Route console log level and logger columns through custom Logback converters so terminals with ANSI support get colored severity badges and compact colored class names. Keep the same habbo.console.style auto/ansi/plain behavior as the startup splash, including plain fallback for non-interactive output, NO_COLOR, and legacy Windows console paths. The file appenders keep their existing verbose patterns unchanged, so debug/error log files remain plain and grep-friendly. Cover the level formatter, logger formatter, override behavior, and Logback pattern wiring with tests. --- .../src/main/java/com/eu/habbo/Emulator.java | 30 +---- .../util/logback/ConsoleLevelConverter.java | 11 ++ .../util/logback/ConsoleLoggerConverter.java | 11 ++ .../eu/habbo/util/logback/ConsoleStyle.java | 106 ++++++++++++++++++ Emulator/src/main/resources/logback.xml | 5 +- .../eu/habbo/ConsoleLogbackLayoutTest.java | 3 +- .../habbo/util/logback/ConsoleStyleTest.java | 49 ++++++++ 7 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLevelConverter.java create mode 100644 Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLoggerConverter.java create mode 100644 Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleStyle.java create mode 100644 Emulator/src/test/java/com/eu/habbo/util/logback/ConsoleStyleTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 765f676a..26f113ce 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -18,6 +18,7 @@ import com.eu.habbo.plugin.events.emulator.EmulatorStartShutdownEvent; import com.eu.habbo.plugin.events.emulator.EmulatorStoppedEvent; import com.eu.habbo.threading.ThreadPooling; import com.eu.habbo.util.imager.badges.BadgeImager; +import com.eu.habbo.util.logback.ConsoleStyle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -368,34 +369,7 @@ public final class Emulator { } static boolean shouldStyleConsole(Map environment, boolean interactiveConsole, String osName, String styleProperty) { - String style = styleProperty == null ? "auto" : styleProperty.trim().toLowerCase(Locale.ROOT); - if (style.equals("ansi") || style.equals("color") || style.equals("colours") || style.equals("colors")) { - return true; - } - if (style.equals("plain") || style.equals("none") || style.equals("false") || style.equals("off")) { - return false; - } - if (!interactiveConsole) { - return false; - } - - Map env = environment == null ? Collections.emptyMap() : environment; - if (env.containsKey("NO_COLOR")) { - return false; - } - if (env.containsKey("WT_SESSION") || env.containsKey("ANSICON") || "ON".equalsIgnoreCase(env.get("ConEmuANSI"))) { - return true; - } - - String term = env.getOrDefault("TERM", ""); - if (term.equalsIgnoreCase("dumb")) { - return false; - } - if (!term.isBlank() && (term.contains("xterm") || term.contains("ansi") || term.contains("screen") || term.contains("tmux"))) { - return true; - } - - return osName == null || !osName.toLowerCase(Locale.ROOT).startsWith("windows"); + return ConsoleStyle.isEnabled(environment, interactiveConsole, osName, styleProperty); } private static String fit(String value, int width) { diff --git a/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLevelConverter.java b/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLevelConverter.java new file mode 100644 index 00000000..43fa820d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLevelConverter.java @@ -0,0 +1,11 @@ +package com.eu.habbo.util.logback; + +import ch.qos.logback.classic.pattern.ClassicConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class ConsoleLevelConverter extends ClassicConverter { + @Override + public String convert(ILoggingEvent event) { + return ConsoleStyle.level(event == null ? null : event.getLevel(), ConsoleStyle.isRuntimeEnabled()); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLoggerConverter.java b/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLoggerConverter.java new file mode 100644 index 00000000..61b997d8 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleLoggerConverter.java @@ -0,0 +1,11 @@ +package com.eu.habbo.util.logback; + +import ch.qos.logback.classic.pattern.ClassicConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class ConsoleLoggerConverter extends ClassicConverter { + @Override + public String convert(ILoggingEvent event) { + return ConsoleStyle.logger(event == null ? "" : event.getLoggerName(), ConsoleStyle.isRuntimeEnabled()); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleStyle.java b/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleStyle.java new file mode 100644 index 00000000..7c59c2aa --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/util/logback/ConsoleStyle.java @@ -0,0 +1,106 @@ +package com.eu.habbo.util.logback; + +import ch.qos.logback.classic.Level; + +import java.util.Collections; +import java.util.Locale; +import java.util.Map; + +public final class ConsoleStyle { + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_BOLD = "\u001B[1m"; + private static final String ANSI_DIM = "\u001B[2m"; + private static final String ANSI_CYAN = "\u001B[36m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_YELLOW = "\u001B[33m"; + private static final String ANSI_RED = "\u001B[31m"; + + private static final int LOGGER_WIDTH = 22; + + private ConsoleStyle() { + } + + public static boolean isRuntimeEnabled() { + return isEnabled( + System.getenv(), + System.console() != null, + System.getProperty("os.name", "Unknown"), + System.getProperty("habbo.console.style", "auto")); + } + + public static boolean isEnabled(Map environment, boolean interactiveConsole, String osName, String styleProperty) { + String style = styleProperty == null ? "auto" : styleProperty.trim().toLowerCase(Locale.ROOT); + if (style.equals("ansi") || style.equals("color") || style.equals("colours") || style.equals("colors")) { + return true; + } + if (style.equals("plain") || style.equals("none") || style.equals("false") || style.equals("off")) { + return false; + } + if (!interactiveConsole) { + return false; + } + + Map env = environment == null ? Collections.emptyMap() : environment; + if (env.containsKey("NO_COLOR")) { + return false; + } + if (env.containsKey("WT_SESSION") || env.containsKey("ANSICON") || "ON".equalsIgnoreCase(env.get("ConEmuANSI"))) { + return true; + } + + String term = env.getOrDefault("TERM", ""); + if (term.equalsIgnoreCase("dumb")) { + return false; + } + if (!term.isBlank() && (term.contains("xterm") || term.contains("ansi") || term.contains("screen") || term.contains("tmux"))) { + return true; + } + + return osName == null || !osName.toLowerCase(Locale.ROOT).startsWith("windows"); + } + + public static String level(Level level, boolean styled) { + String name = level == null ? "INFO" : level.toString(); + String plain = String.format("%-5s", name); + + if (!styled) { + return plain; + } + + if (Level.ERROR.equals(level)) { + return ANSI_BOLD + ANSI_RED + "[x] " + plain + ANSI_RESET; + } + if (Level.WARN.equals(level)) { + return ANSI_YELLOW + "[!] " + plain + ANSI_RESET; + } + if (Level.DEBUG.equals(level) || Level.TRACE.equals(level)) { + return ANSI_DIM + "[.] " + plain + ANSI_RESET; + } + + return ANSI_GREEN + "[i] " + plain + ANSI_RESET; + } + + public static String logger(String loggerName, boolean styled) { + String compact = compactLoggerName(loggerName); + String plain = fit(compact, LOGGER_WIDTH); + return styled ? ANSI_CYAN + plain + ANSI_RESET : plain; + } + + private static String compactLoggerName(String loggerName) { + if (loggerName == null || loggerName.isBlank()) { + return ""; + } + + int lastDot = loggerName.lastIndexOf('.'); + return lastDot >= 0 ? loggerName.substring(lastDot + 1) : loggerName; + } + + private static String fit(String value, int width) { + String safe = value == null ? "" : value; + if (safe.length() > width) { + return safe.substring(0, Math.max(0, width - 3)) + "..."; + } + + return String.format("%-" + width + "s", safe); + } +} diff --git a/Emulator/src/main/resources/logback.xml b/Emulator/src/main/resources/logback.xml index 5a972548..5235b60a 100644 --- a/Emulator/src/main/resources/logback.xml +++ b/Emulator/src/main/resources/logback.xml @@ -1,8 +1,11 @@ + + + - %d{HH:mm:ss.SSS} %-5level [%-12thread] %-22logger{0} | %msg%n + %d{HH:mm:ss.SSS} %morningstarLevel [%-12thread] %morningstarLogger | %msg%n diff --git a/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java b/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java index 0f044868..4e0d3724 100644 --- a/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java +++ b/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java @@ -13,7 +13,8 @@ class ConsoleLogbackLayoutTest { void consolePatternKeepsStartupMessagesReadable() throws Exception { String logback = Files.readString(Path.of("src/main/resources/logback.xml")); - assertTrue(logback.contains("%-22logger{0}"), "console should show compact class names"); + assertTrue(logback.contains("morningstarLevel"), "console should use the adaptive level formatter"); + assertTrue(logback.contains("morningstarLogger"), "console should use the adaptive logger formatter"); assertTrue(logback.contains("| %msg%n"), "console should leave a clear message column"); assertFalse(logback.contains("%-36logger{36}"), "wide package loggers waste console space"); } diff --git a/Emulator/src/test/java/com/eu/habbo/util/logback/ConsoleStyleTest.java b/Emulator/src/test/java/com/eu/habbo/util/logback/ConsoleStyleTest.java new file mode 100644 index 00000000..c62a4e3b --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/util/logback/ConsoleStyleTest.java @@ -0,0 +1,49 @@ +package com.eu.habbo.util.logback; + +import ch.qos.logback.classic.Level; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConsoleStyleTest { + @Test + void formatsLevelWithIconAndColorWhenStyled() { + String formatted = ConsoleStyle.level(Level.WARN, true); + + assertTrue(formatted.contains("\u001B[")); + assertTrue(formatted.contains("[!] WARN ")); + assertTrue(formatted.endsWith("\u001B[0m")); + } + + @Test + void formatsLevelAsPlainTextWhenNotStyled() { + assertEquals("WARN ", ConsoleStyle.level(Level.WARN, false)); + } + + @Test + void formatsLoggerWithColorWhenStyled() { + String formatted = ConsoleStyle.logger("com.eu.habbo.networking.Server", true); + + assertTrue(formatted.contains("\u001B[")); + assertTrue(formatted.contains("Server")); + assertTrue(formatted.endsWith("\u001B[0m")); + } + + @Test + void keepsLoggerPlainAndCompactWhenNotStyled() { + assertEquals("Server ", ConsoleStyle.logger("com.eu.habbo.networking.Server", false)); + } + + @Test + void honorsPlainOverrideEvenInWindowsTerminal() { + assertFalse(ConsoleStyle.isEnabled( + Map.of("WT_SESSION", "abc123"), + true, + "Windows 11", + "plain")); + } +} From eb41e3afb90abae4a125c9e164c184b938201d4f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:20:01 +0200 Subject: [PATCH 10/33] fix(rooms): scope self moderation to current room Reject client-supplied room ids for self-moderation packets unless they match the caller's current room. This prevents users with saved rights or ownership in another room from muting, banning, or unbanning users remotely via crafted packets. RoomUserBanEvent now also ignores invalid ban type values instead of letting valueOf throw through the message handler. Add a contract test covering ban, mute, and unban current-room scoping. --- .../rooms/users/RoomUserBanEvent.java | 17 +++++++++++-- .../rooms/users/RoomUserMuteEvent.java | 19 ++++++++------- .../rooms/users/UnbanRoomUserEvent.java | 12 +++++----- .../RoomModerationScopeContractTest.java | 24 +++++++++++++++++++ 4 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomModerationScopeContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserBanEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserBanEvent.java index d806fd39..df071e0a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserBanEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserBanEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.rooms.users; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.rooms.RoomManager; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.messages.incoming.MessageHandler; public class RoomUserBanEvent extends MessageHandler { @@ -11,6 +12,18 @@ public class RoomUserBanEvent extends MessageHandler { int roomId = this.packet.readInt(); String banName = this.packet.readString(); - Emulator.getGameEnvironment().getRoomManager().banUserFromRoom(this.client.getHabbo(), userId, roomId, RoomManager.RoomBanTypes.valueOf(banName)); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null || room.getId() != roomId) { + return; + } + + RoomManager.RoomBanTypes banType; + try { + banType = RoomManager.RoomBanTypes.valueOf(banName); + } catch (IllegalArgumentException e) { + return; + } + + Emulator.getGameEnvironment().getRoomManager().banUserFromRoom(this.client.getHabbo(), userId, roomId, banType); } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserMuteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserMuteEvent.java index 8acca3b2..1e91d572 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserMuteEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserMuteEvent.java @@ -15,17 +15,18 @@ public class RoomUserMuteEvent extends MessageHandler { int roomId = this.packet.readInt(); int minutes = this.packet.readInt(); - Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null || room.getId() != roomId) { + return; + } - if (room != null) { - if (room.hasRights(this.client.getHabbo()) || this.client.getHabbo().hasPermission("cmd_mute") || this.client.getHabbo().hasPermission(Permission.ACC_AMBASSADOR)) { - Habbo habbo = room.getHabbo(userId); + if (room.hasRights(this.client.getHabbo()) || this.client.getHabbo().hasPermission("cmd_mute") || this.client.getHabbo().hasPermission(Permission.ACC_AMBASSADOR)) { + Habbo habbo = room.getHabbo(userId); - if (habbo != null) { - room.muteHabbo(habbo, minutes); - habbo.getClient().sendResponse(new MutedWhisperComposer(minutes * 60)); - AchievementManager.progressAchievement(this.client.getHabbo(), Emulator.getGameEnvironment().getAchievementManager().getAchievement("SelfModMuteSeen")); - } + if (habbo != null) { + room.muteHabbo(habbo, minutes); + habbo.getClient().sendResponse(new MutedWhisperComposer(minutes * 60)); + AchievementManager.progressAchievement(this.client.getHabbo(), Emulator.getGameEnvironment().getAchievementManager().getAchievement("SelfModMuteSeen")); } } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/UnbanRoomUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/UnbanRoomUserEvent.java index a0eebffb..662d8d30 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/UnbanRoomUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/UnbanRoomUserEvent.java @@ -10,13 +10,13 @@ public class UnbanRoomUserEvent extends MessageHandler { int userId = this.packet.readInt(); int roomId = this.packet.readInt(); - Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); - - if (room != null) { - if (room.isOwner(this.client.getHabbo())) { - room.unbanHabbo(userId); - } + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null || room.getId() != roomId) { + return; } + if (room.isOwner(this.client.getHabbo())) { + room.unbanHabbo(userId); + } } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomModerationScopeContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomModerationScopeContractTest.java new file mode 100644 index 00000000..dd7677a6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomModerationScopeContractTest.java @@ -0,0 +1,24 @@ +package com.eu.habbo.messages.incoming.rooms.users; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RoomModerationScopeContractTest { + @Test + void roomUserBanAndMuteAreScopedToCurrentRoom() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/users"); + + for (String handler : new String[]{"RoomUserBanEvent.java", "RoomUserMuteEvent.java", "UnbanRoomUserEvent.java"}) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("getCurrentRoom()"), + handler + " must authorize room moderation against the user's current room"); + assertTrue(source.contains("room.getId() != roomId"), + handler + " must reject client-supplied room ids that do not match the current room"); + } + } +} From 60ccc8c80b23a2c068cb3f177402ded54e46347c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:28:09 +0200 Subject: [PATCH 11/33] fix(items): require seed ownership for monsterplants Reject monsterplant seed redemption when the caller does not own the placed seed. Without this guard, a user in the same room could trigger ToggleFloorItemEvent against another user's seed and have the server delete that item while creating the monsterplant pet for the attacker. Add a contract test covering the ownership guard before createMonsterplant is reached. --- .../rooms/items/ToggleFloorItemEvent.java | 4 +++ ...MonsterPlantSeedOwnershipContractTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java index 45098095..8bf4942b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java @@ -103,6 +103,10 @@ public class ToggleFloorItemEvent extends MessageHandler { // Do not move to onClick(). Wired could trigger it. if (item instanceof InteractionMonsterPlantSeed) { + if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) { + return; + } + Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); boolean isRare = item.getBaseItem().getName().contains("rare"); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java new file mode 100644 index 00000000..e8516c19 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java @@ -0,0 +1,29 @@ +package com.eu.habbo.messages.incoming.rooms.items; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MonsterPlantSeedOwnershipContractTest { + @Test + void monsterPlantSeedsCanOnlyBeRedeemedByTheirOwner() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java")); + int seedBranch = source.indexOf("item instanceof InteractionMonsterPlantSeed"); + + assertTrue(seedBranch >= 0, "ToggleFloorItemEvent must keep monsterplant seed handling explicit"); + + String seedHandling = source.substring(seedBranch, Math.min(source.length(), seedBranch + 1400)); + + String ownershipGuard = "if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId())"; + + assertTrue(seedHandling.contains(ownershipGuard), + "Monsterplant seed redemption must reject callers who do not own the seed"); + assertTrue(seedHandling.contains("createMonsterplant"), + "Monsterplant seed handling must create the pet inside the guarded branch"); + assertTrue(seedHandling.indexOf(ownershipGuard) < seedHandling.indexOf("createMonsterplant"), + "Ownership rejection must happen before creating the pet"); + } +} From 82c6f3f9ff858cb164341f685f17fb1b910fc067 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:34:55 +0200 Subject: [PATCH 12/33] fix(items): charge rentable space purchases Deduct the computed rent cost when a user rents an InteractionRentableSpace. The previous flow only checked that the user had enough credits, then marked the space as rented without charging them, allowing free weekly rentals. Honor ACC_INFINITE_CREDITS for staff accounts and add a contract test that keeps the charge before the rented state is assigned. --- .../InteractionRentableSpace.java | 9 +++++- .../RentableSpaceChargeContractTest.java | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java index 8bd18bd3..e900e4f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java @@ -3,6 +3,7 @@ package com.eu.habbo.habbohotel.items.interactions; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.gameclients.GameClient; import com.eu.habbo.habbohotel.items.Item; +import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.rooms.RoomLayout; import com.eu.habbo.habbohotel.rooms.RoomUnit; @@ -133,12 +134,18 @@ public class InteractionRentableSpace extends HabboItem { if (habbo.getHabboStats().isRentingSpace()) return; - if (habbo.getHabboInfo().getCredits() < this.rentCost()) + int cost = this.rentCost(); + boolean hasInfiniteCredits = habbo.hasPermission(Permission.ACC_INFINITE_CREDITS); + if (!hasInfiniteCredits && habbo.getHabboInfo().getCredits() < cost) return; if (habbo.getHabboStats().getClubExpireTimestamp() < Emulator.getIntUnixTimestamp()) return; + if (!hasInfiniteCredits) { + habbo.giveCredits(-cost); + } + this.setRenterId(habbo.getHabboInfo().getId()); this.setRenterName(habbo.getHabboInfo().getUsername()); this.setEndTimestamp(Emulator.getIntUnixTimestamp() + (7 * 86400)); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java new file mode 100644 index 00000000..24cb1818 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java @@ -0,0 +1,31 @@ +package com.eu.habbo.habbohotel.items.interactions; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RentableSpaceChargeContractTest { + @Test + void rentingSpaceChargesCreditsBeforeMarkingSpaceRented() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java")); + int rentMethod = source.indexOf("public void rent(Habbo habbo)"); + + assertTrue(rentMethod >= 0, "InteractionRentableSpace must keep explicit rent handling"); + + String rentHandling = source.substring(rentMethod, Math.min(source.length(), rentMethod + 1400)); + + assertTrue(rentHandling.contains("int cost = this.rentCost();"), + "Rent cost must be computed once before charging"); + assertTrue(rentHandling.contains("boolean hasInfiniteCredits = habbo.hasPermission(Permission.ACC_INFINITE_CREDITS);"), + "Renting must honor infinite-credit staff permission before charging"); + assertTrue(rentHandling.contains("!hasInfiniteCredits && habbo.getHabboInfo().getCredits() < cost"), + "Renting must reject non-staff users without enough credits for the computed cost"); + assertTrue(rentHandling.contains("habbo.giveCredits(-cost);"), + "Renting must deduct the computed credit cost"); + assertTrue(rentHandling.indexOf("habbo.giveCredits(-cost);") < rentHandling.indexOf("this.setRenterId"), + "Credits must be charged before the rentable space is marked as rented"); + } +} From 43596506211b261b0057e3c7f1f5f68d14f29538 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 15:38:06 +0200 Subject: [PATCH 13/33] fix(texts): add missing command descriptions --- Database Updates/003_live_required_schema.sql | 10 +++++ Database Updates/009_mentions_wordfilter.sql | 4 ++ .../Own_Database_RunFirst/011_HotelLogin.sql | 1 + .../016_custom_prefixes_setup.sql | 3 ++ Default Database/FullDatabase.sql | 10 +++++ .../CommandDescriptionTextsContractTest.java | 45 +++++++++++++++++++ 6 files changed, 73 insertions(+) create mode 100644 Emulator/src/test/java/com/eu/habbo/core/CommandDescriptionTextsContractTest.java diff --git a/Database Updates/003_live_required_schema.sql b/Database Updates/003_live_required_schema.sql index f1e795b7..5d8b5462 100644 --- a/Database Updates/003_live_required_schema.sql +++ b/Database Updates/003_live_required_schema.sql @@ -435,6 +435,16 @@ ON DUPLICATE KEY UPDATE `triggers_talking_furniture` = VALUES(`triggers_talking_furniture`); INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES + ('commands.description.acc_modtool_room_info', 'Allows viewing room information in the moderation tool.'), + ('commands.description.cmd_add_youtube_playlist', ':add_youtube '), + ('commands.description.cmd_disablemassmentions', ':disablemassmentions'), + ('commands.description.cmd_disablementions', ':disablementions'), + ('commands.description.cmd_give_prefix', ':giveprefix [icon] [effect]'), + ('commands.description.cmd_hidewired', ':hidewired'), + ('commands.description.cmd_list_prefixes', ':listprefixes '), + ('commands.description.cmd_remove_prefix', ':removeprefix '), + ('commands.description.cmd_setroom_template', ':setroom_template'), + ('commands.description.cmd_update_youtube_playlists', ':update_youtube'), ('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'), ('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'), ('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'), diff --git a/Database Updates/009_mentions_wordfilter.sql b/Database Updates/009_mentions_wordfilter.sql index 2d4935b6..49d08368 100644 --- a/Database Updates/009_mentions_wordfilter.sql +++ b/Database Updates/009_mentions_wordfilter.sql @@ -37,6 +37,10 @@ VALUES ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); +INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES + ('commands.description.cmd_disablementions', ':disablementions'), + ('commands.description.cmd_disablemassmentions', ':disablemassmentions'); + -- ---------------------------------------------------------------------------- -- 3. Emulator settings: cooldowns, caps and alias lists diff --git a/Database Updates/Own_Database_RunFirst/011_HotelLogin.sql b/Database Updates/Own_Database_RunFirst/011_HotelLogin.sql index 666a9b5f..639f8652 100644 --- a/Database Updates/Own_Database_RunFirst/011_HotelLogin.sql +++ b/Database Updates/Own_Database_RunFirst/011_HotelLogin.sql @@ -49,6 +49,7 @@ INSERT INTO `permission_definitions` (`permission_key`, `rank_7`, `comment`) VAL ON DUPLICATE KEY UPDATE `rank_7` = VALUES(`rank_7`); INSERT INTO `emulator_texts` (`key`, `value`) VALUES + ('commands.description.cmd_setroom_template', ':setroom_template'), ('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'), ('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'), ('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'), diff --git a/Database Updates/Own_Database_RunFirst/016_custom_prefixes_setup.sql b/Database Updates/Own_Database_RunFirst/016_custom_prefixes_setup.sql index dd48a23f..080376ff 100644 --- a/Database Updates/Own_Database_RunFirst/016_custom_prefixes_setup.sql +++ b/Database Updates/Own_Database_RunFirst/016_custom_prefixes_setup.sql @@ -301,6 +301,7 @@ INSERT IGNORE INTO `custom_prefixes_catalog` INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES -- GivePrefix command + ('commands.description.cmd_give_prefix', ':giveprefix [icon] [effect]'), ('commands.keys.cmd_give_prefix', 'giveprefix'), ('commands.error.cmd_give_prefix.usage', 'Usage: :giveprefix [icon] [effect]'), ('commands.error.cmd_give_prefix.invalid_color', 'Invalid color format. Use hex format (#FF0000).'), @@ -308,12 +309,14 @@ INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES ('commands.error.cmd_give_prefix.user_not_found', 'User not found or not online.'), ('commands.succes.cmd_give_prefix', 'Prefix {%prefix%} successfully given to %user%!'), -- ListPrefixes command + ('commands.description.cmd_list_prefixes', ':listprefixes '), ('commands.keys.cmd_list_prefixes', 'listprefixes'), ('commands.error.cmd_list_prefixes.usage', 'Usage: :listprefixes '), ('commands.error.cmd_list_prefixes.user_not_found', 'User not found or not online.'), ('commands.succes.cmd_list_prefixes.header', 'Prefixes of %user%:'), ('commands.succes.cmd_list_prefixes.empty', '%user% has no prefixes.'), -- RemovePrefix command + ('commands.description.cmd_remove_prefix', ':removeprefix '), ('commands.keys.cmd_remove_prefix', 'removeprefix'), ('commands.error.cmd_remove_prefix.usage', 'Usage: :removeprefix '), ('commands.error.cmd_remove_prefix.user_not_found', 'User not found or not online.'), diff --git a/Default Database/FullDatabase.sql b/Default Database/FullDatabase.sql index c7e85d40..ff7f9c3b 100644 --- a/Default Database/FullDatabase.sql +++ b/Default Database/FullDatabase.sql @@ -15355,7 +15355,9 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.cmd_promote_offer.list', 'All available offers (%amount%):
%list%'), ('commands.cmd_promote_offer.list.entry', '%id%: %title% %description%'), ('commands.description.acc_debug', ':test [header] i:1 s:a b:1'), + ('commands.description.acc_modtool_room_info', 'Allows viewing room information in the moderation tool.'), ('commands.description.cmd_about', ':about'), + ('commands.description.cmd_add_youtube_playlist', ':add_youtube '), ('commands.description.cmd_alert', ':alert '), ('commands.description.cmd_allow_trading', 'Enables / Disables the tradelock for a user.'), ('commands.description.cmd_badge', ':badge '), @@ -15379,6 +15381,8 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.description.cmd_danceall', ':danceall '), ('commands.description.cmd_diagonal', ':diagonal'), ('commands.description.cmd_disable_effects', ':disableffects'), + ('commands.description.cmd_disablemassmentions', ':disablemassmentions'), + ('commands.description.cmd_disablementions', ':disablementions'), ('commands.description.cmd_disconnect', ':disconnect '), ('commands.description.cmd_duckets', ':duckets '), ('commands.description.cmd_ejectall', ':ejectall'), @@ -15395,11 +15399,13 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.description.cmd_freeze_bots', ':freezebots'), ('commands.description.cmd_furnidata', ':furnidata'), ('commands.description.cmd_gift', ':gift '), + ('commands.description.cmd_give_prefix', ':giveprefix [icon] [effect]'), ('commands.description.cmd_give_rank', ':giverank '), ('commands.description.cmd_ha', ':ha '), ('commands.description.cmd_hal', ':hal '), ('commands.description.cmd_hand_item', ':handitem '), ('commands.description.cmd_happyhour', ':happyhour'), + ('commands.description.cmd_hidewired', ':hidewired'), ('commands.description.cmd_hoverboard', ':hoverboard'), ('commands.description.cmd_hug', ':hug '), ('commands.description.cmd_invisible', ':invisible'), @@ -15408,6 +15414,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.description.cmd_kill', ':kill '), ('commands.description.cmd_kiss', ':kiss '), ('commands.description.cmd_lay', ':lay'), + ('commands.description.cmd_list_prefixes', ':listprefixes '), ('commands.description.cmd_machine_ban', ':machineban [reason]'), ('commands.description.cmd_massbadge', ':massbadge '), ('commands.description.cmd_masscredits', ':masscredits '), @@ -15429,6 +15436,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.description.cmd_push', ':push '), ('commands.description.cmd_redeem', ':redeem'), ('commands.description.cmd_reload_room', ':reload_room'), + ('commands.description.cmd_remove_prefix', ':removeprefix '), ('commands.description.cmd_roomalert', ':roomalert '), ('commands.description.cmd_roombadge', ':roombadge '), ('commands.description.cmd_roomcredits', ':roomcredits '), @@ -15444,6 +15452,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.description.cmd_set', ':set info'), ('commands.description.cmd_setmax', ':setmax '), ('commands.description.cmd_setpublic', ':setpublic'), + ('commands.description.cmd_setroom_template', ':setroom_template'), ('commands.description.cmd_setrotation', ':rot;rotation'), ('commands.description.cmd_setspeed', ':setspeed '), ('commands.description.cmd_setstate', ':ss'), @@ -15486,6 +15495,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES ('commands.description.cmd_update_polls', ':update_polls'), ('commands.description.cmd_update_texts', ':update_texts'), ('commands.description.cmd_update_wordfilter', ':update_word_filter'), + ('commands.description.cmd_update_youtube_playlists', ':update_youtube'), ('commands.description.cmd_userinfo', ':userinfo '), ('commands.description.cmd_welcome', ':welcome '), ('commands.description.cmd_word_quiz', ':wordquiz '), diff --git a/Emulator/src/test/java/com/eu/habbo/core/CommandDescriptionTextsContractTest.java b/Emulator/src/test/java/com/eu/habbo/core/CommandDescriptionTextsContractTest.java new file mode 100644 index 00000000..b9966823 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/core/CommandDescriptionTextsContractTest.java @@ -0,0 +1,45 @@ +package com.eu.habbo.core; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class CommandDescriptionTextsContractTest { + private static final Path FULL_DATABASE = Path.of("../Default Database/FullDatabase.sql"); + private static final Path LIVE_SCHEMA_UPDATE = Path.of("../Database Updates/003_live_required_schema.sql"); + + private static final List REQUIRED_DESCRIPTION_KEYS = List.of( + "commands.description.acc_modtool_room_info", + "commands.description.cmd_add_youtube_playlist", + "commands.description.cmd_disablemassmentions", + "commands.description.cmd_disablementions", + "commands.description.cmd_give_prefix", + "commands.description.cmd_hidewired", + "commands.description.cmd_list_prefixes", + "commands.description.cmd_remove_prefix", + "commands.description.cmd_setroom_template", + "commands.description.cmd_update_youtube_playlists" + ); + + @Test + void fullDatabaseDefinesCommandDescriptionsUsedByCommandsList() throws IOException { + assertContainsAllDescriptionKeys(Files.readString(FULL_DATABASE), "FullDatabase.sql"); + } + + @Test + void liveSchemaUpdateBackfillsCommandDescriptionsForExistingDatabases() throws IOException { + assertContainsAllDescriptionKeys(Files.readString(LIVE_SCHEMA_UPDATE), "003_live_required_schema.sql"); + } + + private static void assertContainsAllDescriptionKeys(String source, String fileName) { + for (String key : REQUIRED_DESCRIPTION_KEYS) { + assertTrue(source.contains("'" + key + "'"), + fileName + " must define " + key + " to avoid TextsManager missing-key logs"); + } + } +} From 7a7e38311dfbf84dda8c3186b3eaa0771bacea60 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 15:45:55 +0200 Subject: [PATCH 14/33] fix(guilds): validate badge packet parts --- .../habbohotel/guilds/GuildBadgeBuilder.java | 52 +++++++++++++++ .../guilds/GuildChangeBadgeEvent.java | 22 +------ .../incoming/guilds/RequestGuildBuyEvent.java | 25 ++------ .../java/com/eu/habbo/util/PacketGuard.java | 25 ++++++++ .../guilds/GuildBadgeBuilderTest.java | 63 +++++++++++++++++++ 5 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilder.java create mode 100644 Emulator/src/main/java/com/eu/habbo/util/PacketGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilderTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilder.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilder.java new file mode 100644 index 00000000..0e317809 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilder.java @@ -0,0 +1,52 @@ +package com.eu.habbo.habbohotel.guilds; + +import com.eu.habbo.messages.ClientMessage; +import com.eu.habbo.util.PacketGuard; + +public final class GuildBadgeBuilder { + public static final int MAX_BADGE_PARTS = 5; + private static final int INTS_PER_PART = 3; + private static final int BYTES_PER_INT = 4; + private static final int MAX_PART_ID = 999; + private static final int MAX_COLOR_ID = 99; + private static final int MAX_POSITION = 8; + + private GuildBadgeBuilder() { + } + + public static String readBadge(ClientMessage packet, int flatPartValueCount) { + if (flatPartValueCount % INTS_PER_PART != 0) { + return null; + } + + int partCount = flatPartValueCount / INTS_PER_PART; + if (!PacketGuard.isCountInRange(partCount, 1, MAX_BADGE_PARTS) + || !PacketGuard.hasFixedWidthEntries(packet, flatPartValueCount, BYTES_PER_INT)) { + return null; + } + + StringBuilder badge = new StringBuilder(partCount * 6); + for (int partIndex = 0; partIndex < partCount; partIndex++) { + int id = packet.readInt(); + int color = packet.readInt(); + int position = packet.readInt(); + + if (!isValidPart(id, color, position)) { + return null; + } + + badge.append(partIndex == 0 ? "b" : "s"); + badge.append(id < 100 ? "0" : "").append(id < 10 ? "0" : "").append(id); + badge.append(color < 10 ? "0" : "").append(color); + badge.append(position); + } + + return badge.toString(); + } + + private static boolean isValidPart(int id, int color, int position) { + return id >= 0 && id <= MAX_PART_ID + && color >= 0 && color <= MAX_COLOR_ID + && position >= 0 && position <= MAX_POSITION; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeBadgeEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeBadgeEvent.java index 9aa68c8e..5dde4604 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeBadgeEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeBadgeEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.guilds; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.guilds.Guild; +import com.eu.habbo.habbohotel.guilds.GuildBadgeBuilder; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.messages.incoming.MessageHandler; @@ -27,25 +28,8 @@ public class GuildChangeBadgeEvent extends MessageHandler { int count = this.packet.readInt(); - String badge = ""; - - byte base = 1; - - while (base < count) { - int id = this.packet.readInt(); - int color = this.packet.readInt(); - int pos = this.packet.readInt(); - - if (base == 1) { - badge += "b"; - } else { - badge += "s"; - } - - badge += (id < 100 ? "0" : "") + (id < 10 ? "0" : "") + id + (color < 10 ? "0" : "") + color + "" + pos; - - base += 3; - } + String badge = GuildBadgeBuilder.readBadge(this.packet, count); + if (badge == null) return; if (guild.getBadge().equalsIgnoreCase(badge)) return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/RequestGuildBuyEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/RequestGuildBuyEvent.java index 976170c1..1b7a8c0b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/RequestGuildBuyEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/RequestGuildBuyEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.guilds; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.guilds.Guild; +import com.eu.habbo.habbohotel.guilds.GuildBadgeBuilder; import com.eu.habbo.habbohotel.modtool.ScripterManager; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; @@ -69,24 +70,10 @@ public class RequestGuildBuyEvent extends MessageHandler { int count = this.packet.readInt(); - StringBuilder badge = new StringBuilder(); - - byte base = 1; - - while (base < count) { - int id = this.packet.readInt(); - int color = this.packet.readInt(); - int pos = this.packet.readInt(); - - if (base == 1) { - badge.append("b"); - } else { - badge.append("s"); - } - - badge.append(id < 100 ? "0" : "").append(id < 10 ? "0" : "").append(id).append(color < 10 ? "0" : "").append(color).append(pos); - - base += 3; + String badge = GuildBadgeBuilder.readBadge(this.packet, count); + if (badge == null) { + this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); + return; } // Only charge the player once every step has been validated. Previously the @@ -103,7 +90,7 @@ public class RequestGuildBuyEvent extends MessageHandler { } } - Guild guild = Emulator.getGameEnvironment().getGuildManager().createGuild(this.client.getHabbo(), roomId, r.getName(), name, description, badge.toString(), colorOne, colorTwo); + Guild guild = Emulator.getGameEnvironment().getGuildManager().createGuild(this.client.getHabbo(), roomId, r.getName(), name, description, badge, colorOne, colorTwo); r.setGuild(guild.getId()); r.removeAllRights(); diff --git a/Emulator/src/main/java/com/eu/habbo/util/PacketGuard.java b/Emulator/src/main/java/com/eu/habbo/util/PacketGuard.java new file mode 100644 index 00000000..e35d5bae --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/util/PacketGuard.java @@ -0,0 +1,25 @@ +package com.eu.habbo.util; + +import com.eu.habbo.messages.ClientMessage; + +public final class PacketGuard { + private PacketGuard() { + } + + public static boolean isCountInRange(int count, int min, int max) { + return count >= min && count <= max; + } + + public static boolean hasReadableBytes(ClientMessage packet, int requiredBytes) { + return packet != null && requiredBytes >= 0 && packet.bytesAvailable() >= requiredBytes; + } + + public static boolean hasFixedWidthEntries(ClientMessage packet, int entryCount, int bytesPerEntry) { + if (packet == null || entryCount < 0 || bytesPerEntry < 0) { + return false; + } + + long requiredBytes = (long) entryCount * bytesPerEntry; + return requiredBytes <= Integer.MAX_VALUE && packet.bytesAvailable() >= requiredBytes; + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilderTest.java new file mode 100644 index 00000000..642a6a4c --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildBadgeBuilderTest.java @@ -0,0 +1,63 @@ +package com.eu.habbo.habbohotel.guilds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.eu.habbo.messages.ClientMessage; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; + +class GuildBadgeBuilderTest { + @Test + void buildsBadgeFromFlatPartTriplets() { + ClientMessage packet = messageWithInts( + 1, 2, 4, + 35, 8, 0 + ); + + assertEquals("b001024s035080", GuildBadgeBuilder.readBadge(packet, 6)); + } + + @Test + void rejectsCountThatDoesNotRepresentCompleteTriplets() { + ClientMessage packet = messageWithInts(1, 2, 4); + + assertNull(GuildBadgeBuilder.readBadge(packet, 4)); + } + + @Test + void rejectsPayloadShorterThanDeclaredCount() { + ClientMessage packet = messageWithInts(1, 2); + + assertNull(GuildBadgeBuilder.readBadge(packet, 3)); + } + + @Test + void rejectsTooManyBadgeParts() { + ClientMessage packet = messageWithInts( + 1, 1, 4, + 2, 1, 4, + 3, 1, 4, + 4, 1, 4, + 5, 1, 4, + 6, 1, 4 + ); + + assertNull(GuildBadgeBuilder.readBadge(packet, 18)); + } + + @Test + void rejectsPartValuesOutsideBadgeCodeRanges() { + assertNull(GuildBadgeBuilder.readBadge(messageWithInts(1000, 1, 4), 3)); + assertNull(GuildBadgeBuilder.readBadge(messageWithInts(1, 100, 4), 3)); + assertNull(GuildBadgeBuilder.readBadge(messageWithInts(1, 1, 9), 3)); + } + + private static ClientMessage messageWithInts(int... values) { + var buffer = Unpooled.buffer(values.length * Integer.BYTES); + for (int value : values) { + buffer.writeInt(value); + } + return new ClientMessage(0, buffer); + } +} From fdcd3a73234dd9d77b2b161213319a68fade7914 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:22:52 +0200 Subject: [PATCH 15/33] fix(furnieditor): validate item update payloads --- .../furnieditor/FurniEditorDeleteEvent.java | 4 +- .../furnieditor/FurniEditorDetailEvent.java | 4 +- .../furnieditor/FurniEditorHelper.java | 9 ++ .../furnieditor/FurniEditorUpdateEvent.java | 58 ++------ .../furnieditor/FurniEditorUpdatePayload.java | 133 ++++++++++++++++++ .../FurniEditorUpdatePayloadTest.java | 52 +++++++ 6 files changed, 207 insertions(+), 53 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java index b31194dd..64c745bd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java @@ -57,8 +57,8 @@ public class FurniEditorDeleteEvent extends MessageHandler { // Check catalog_items references int catalogCount = 0; try (PreparedStatement stmt = connection.prepareStatement( - "SELECT COUNT(*) FROM catalog_items WHERE item_ids LIKE ?")) { - stmt.setString(1, "%" + id + "%"); + "SELECT COUNT(*) FROM catalog_items WHERE " + FurniEditorHelper.catalogItemIdsTokenSql("item_ids"))) { + stmt.setString(1, FurniEditorHelper.catalogItemIdsTokenPattern(id)); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { catalogCount = rs.getInt(1); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java index c4917fd1..e559633e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java @@ -75,8 +75,8 @@ public class FurniEditorDetailEvent extends MessageHandler { "ci.page_id AS ci_page_id, COALESCE(cp.caption, '') AS page_caption " + "FROM catalog_items ci " + "LEFT JOIN catalog_pages cp ON ci.page_id = cp.id " + - "WHERE ci.item_ids LIKE ?")) { - stmt.setString(1, "%" + itemId + "%"); + "WHERE " + FurniEditorHelper.catalogItemIdsTokenSql("ci.item_ids"))) { + stmt.setString(1, FurniEditorHelper.catalogItemIdsTokenPattern(itemId)); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { catalogItems.add(FurniEditorHelper.readCatalogRef(rs)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java index cd7f7a82..94a8305f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java @@ -11,6 +11,15 @@ import java.util.Map; * FurniEditorSearchEvent to ensure consistent field reading. */ public class FurniEditorHelper { + public static final String CATALOG_ITEM_IDS_TOKEN_SQL = "CONCAT(',', REPLACE(item_ids, ' ', ''), ',') LIKE ?"; + + public static String catalogItemIdsTokenSql(String column) { + return "CONCAT(',', REPLACE(" + column + ", ' ', ''), ',') LIKE ?"; + } + + public static String catalogItemIdsTokenPattern(int itemId) { + return "%," + itemId + ",%"; + } /** * Read the 14 base fields from items_base into a Map. diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java index 845395f5..e0b14f94 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java @@ -4,15 +4,11 @@ import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.sql.Connection; import java.sql.PreparedStatement; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; public class FurniEditorUpdateEvent extends MessageHandler { @@ -39,57 +35,18 @@ public class FurniEditorUpdateEvent extends MessageHandler { return; } - if (json.size() == 0) { - this.client.sendResponse(new FurniEditorResultComposer(false, "No fields to update")); + FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(json); + if (!payload.valid()) { + this.client.sendResponse(new FurniEditorResultComposer(false, payload.error)); return; } - // Build dynamic UPDATE with whitelisted fields - StringBuilder setClauses = new StringBuilder(); - List values = new ArrayList<>(); - - for (Map.Entry entry : json.entrySet()) { - String jsKey = entry.getKey(); - String dbColumn = FurniEditorHelper.FIELD_MAP.get(jsKey); - - if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) { - continue; // Skip unknown or disallowed fields - } - - if (setClauses.length() > 0) setClauses.append(", "); - setClauses.append("`").append(dbColumn).append("` = ?"); - - JsonElement val = entry.getValue(); - if (val.isJsonPrimitive()) { - if (val.getAsJsonPrimitive().isBoolean()) { - values.add(val.getAsBoolean() ? "1" : "0"); - } else if (val.getAsJsonPrimitive().isNumber()) { - // Check if it's a decimal number - String numStr = val.getAsString(); - if (numStr.contains(".")) { - values.add(val.getAsDouble()); - } else { - values.add(val.getAsInt()); - } - } else { - values.add(val.getAsString()); - } - } else { - values.add(val.toString()); - } - } - - if (setClauses.length() == 0) { - this.client.sendResponse(new FurniEditorResultComposer(false, "No valid fields to update")); - return; - } - - String sql = "UPDATE items_base SET " + setClauses + " WHERE id = ?"; + String sql = "UPDATE items_base SET " + payload.setClauses + " WHERE id = ?"; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement stmt = connection.prepareStatement(sql)) { int idx = 1; - for (Object value : values) { + for (Object value : payload.values) { if (value instanceof Integer) { stmt.setInt(idx++, (Integer) value); } else if (value instanceof Double) { @@ -99,7 +56,10 @@ public class FurniEditorUpdateEvent extends MessageHandler { } } stmt.setInt(idx, id); - stmt.executeUpdate(); + if (stmt.executeUpdate() == 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found: " + id)); + return; + } } // Reload emulator item definitions diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java new file mode 100644 index 00000000..3dfb1d80 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayload.java @@ -0,0 +1,133 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class FurniEditorUpdatePayload { + public final String setClauses; + public final List values; + public final String error; + + private FurniEditorUpdatePayload(String setClauses, List values, String error) { + this.setClauses = setClauses; + this.values = values; + this.error = error; + } + + public static FurniEditorUpdatePayload validate(JsonObject json) { + if (json == null || json.size() == 0) { + return invalid("No fields to update"); + } + + StringBuilder setClauses = new StringBuilder(); + List values = new ArrayList<>(); + + for (Map.Entry entry : json.entrySet()) { + String dbColumn = FurniEditorHelper.FIELD_MAP.get(entry.getKey()); + if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) { + continue; + } + + Object value = validateValue(dbColumn, entry.getValue()); + if (value == null) { + return invalid("Invalid value for " + entry.getKey()); + } + + if (setClauses.length() > 0) setClauses.append(", "); + setClauses.append("`").append(dbColumn).append("` = ?"); + values.add(value); + } + + if (setClauses.length() == 0) { + return invalid("No valid fields to update"); + } + + return new FurniEditorUpdatePayload(setClauses.toString(), values, null); + } + + public boolean valid() { + return this.error == null; + } + + private static FurniEditorUpdatePayload invalid(String error) { + return new FurniEditorUpdatePayload("", List.of(), error); + } + + private static Object validateValue(String dbColumn, JsonElement element) { + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + + JsonPrimitive primitive = element.getAsJsonPrimitive(); + return switch (dbColumn) { + case "public_name" -> boundedString(primitive, 0, 56); + case "type" -> itemType(primitive); + case "width", "length" -> boundedInt(primitive, 0, 64); + case "stack_height" -> boundedDouble(primitive, 0.0D, 99.99D); + case "allow_stack", "allow_walk", "allow_sit", "allow_lay", "allow_gift", + "allow_trade", "allow_recycle", "allow_marketplace_sell", "allow_inventory_stack" -> booleanFlag(primitive); + case "interaction_type" -> boundedString(primitive, 0, 500); + case "interaction_modes_count" -> boundedInt(primitive, 0, 100); + case "vending_ids", "clothing_on_walk" -> boundedString(primitive, 0, 255); + case "customparams" -> boundedString(primitive, 0, 256); + case "multiheight" -> boundedString(primitive, 0, 50); + case "effect_id_male", "effect_id_female", "sprite_id" -> boundedInt(primitive, 0, Integer.MAX_VALUE); + case "description" -> boundedString(primitive, 0, 500); + default -> null; + }; + } + + private static String boundedString(JsonPrimitive primitive, int minLength, int maxLength) { + if (!primitive.isString()) return null; + String value = primitive.getAsString(); + if (value.length() < minLength || value.length() > maxLength) return null; + return value; + } + + private static String itemType(JsonPrimitive primitive) { + String value = boundedString(primitive, 1, 3); + if (value == null) return null; + return value.matches("[a-z]+") ? value : null; + } + + private static Integer boundedInt(JsonPrimitive primitive, int min, int max) { + try { + int value = primitive.getAsInt(); + return value >= min && value <= max ? value : null; + } catch (Exception e) { + return null; + } + } + + private static Double boundedDouble(JsonPrimitive primitive, double min, double max) { + try { + double value = primitive.getAsDouble(); + return Double.isFinite(value) && value >= min && value <= max ? value : null; + } catch (Exception e) { + return null; + } + } + + private static String booleanFlag(JsonPrimitive primitive) { + if (primitive.isBoolean()) { + return primitive.getAsBoolean() ? "1" : "0"; + } + + if (primitive.isNumber()) { + int value = primitive.getAsInt(); + return value == 0 || value == 1 ? String.valueOf(value) : null; + } + + if (primitive.isString()) { + String value = primitive.getAsString(); + return "0".equals(value) || "1".equals(value) ? value : null; + } + + return null; + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java new file mode 100644 index 00000000..de0ba5f5 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdatePayloadTest.java @@ -0,0 +1,52 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FurniEditorUpdatePayloadTest { + @Test + void acceptsSafeEditorFields() { + FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(JsonParser.parseString(""" + { + "publicName": "Rare Chair", + "type": "s", + "width": 2, + "length": 1, + "stackHeight": 1.5, + "allowTrade": true, + "interactionModesCount": 3 + } + """).getAsJsonObject()); + + assertTrue(payload.valid()); + assertEquals(7, payload.values.size()); + } + + @Test + void rejectsOutOfRangeAndOversizedFields() { + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"width\":-1}").getAsJsonObject()).valid()); + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"stackHeight\":1000}").getAsJsonObject()).valid()); + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"allowTrade\":2}").getAsJsonObject()).valid()); + assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"publicName\":\"" + "x".repeat(57) + "\"}").getAsJsonObject()).valid()); + } + + @Test + void ignoresUnknownFieldsButRequiresAtLeastOneValidField() { + FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate( + JsonParser.parseString("{\"itemName\":\"blocked\",\"unknown\":true}").getAsJsonObject()); + + assertFalse(payload.valid()); + assertEquals("No valid fields to update", payload.error); + } + + @Test + void buildsCatalogItemIdsTokenPattern() { + assertEquals("%,12,%", FurniEditorHelper.catalogItemIdsTokenPattern(12)); + assertTrue((",112,12,13,").contains(",12,")); + assertFalse((",112,13,").contains(",12,")); + } +} From c9214bac076a894081c0c632aa4156af01fffd24 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:39:45 +0200 Subject: [PATCH 16/33] fix(catalog): guard page mutations --- .../CatalogAdminCreatePageEvent.java | 2 +- .../CatalogAdminDeletePageEvent.java | 5 +- .../CatalogAdminMovePageEvent.java | 35 +++++++----- .../CatalogAdminSavePageEvent.java | 13 +++-- .../CatalogAdminPageMutationContractTest.java | 57 +++++++++++++++++++ 5 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminPageMutationContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java index 420e21c0..879eb9b0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java @@ -36,7 +36,7 @@ public class CatalogAdminCreatePageEvent extends MessageHandler { pageLayout = CatalogPageLayouts.default_3x3; } - if (parentId != -1 && Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId) == null) { + if (parentId != -1 && Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId, pageType) == null) { this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + parentId)); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java index c72f0273..f44c08cf 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java @@ -36,7 +36,10 @@ public class CatalogAdminDeletePageEvent extends MessageHandler { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement(query)) { statement.setInt(1, pageId); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); + return; + } } Emulator.getGameEnvironment().getCatalogManager().getCatalogPagesMap(pageType).remove(pageId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java index c88725c0..0e2a524f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java @@ -28,12 +28,21 @@ public class CatalogAdminMovePageEvent extends MessageHandler { CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString()); String tableName = (pageType == CatalogPageType.BUILDER) ? "catalog_pages_bc" : "catalog_pages"; + CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType); + if (page == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); + return; + } + if (newParentId == -1) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "UPDATE " + tableName + " SET enabled = IF(enabled = '1', '0', '1') WHERE id = ?")) { statement.setInt(1, pageId); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); + return; + } } this.client.sendResponse(new CatalogAdminResultComposer(true, "Page toggled")); return; @@ -44,30 +53,27 @@ public class CatalogAdminMovePageEvent extends MessageHandler { PreparedStatement statement = connection.prepareStatement( "UPDATE " + tableName + " SET visible = IF(visible = '1', '0', '1') WHERE id = ?")) { statement.setInt(1, pageId); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); + return; + } } this.client.sendResponse(new CatalogAdminResultComposer(true, "Visibility toggled")); return; } - - CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType); - if (page == null) { - this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); - return; - } if (newParentId == pageId) { this.client.sendResponse(new CatalogAdminResultComposer(false, "A page cannot be its own parent")); return; } - CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(newParentId); + CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(newParentId, pageType); if (parent == null) { this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + newParentId)); return; } - if (this.wouldCreateCycle(pageId, newParentId)) { + if (this.wouldCreateCycle(pageId, newParentId, pageType)) { this.client.sendResponse(new CatalogAdminResultComposer(false, "Refusing to move: that would create a cycle")); return; } @@ -80,18 +86,21 @@ public class CatalogAdminMovePageEvent extends MessageHandler { statement.setInt(1, newParentId); statement.setInt(2, newIndex); statement.setInt(3, pageId); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); + return; + } } this.client.sendResponse(new CatalogAdminResultComposer(true, "Page moved")); } - private boolean wouldCreateCycle(int pageId, int parentId) { + private boolean wouldCreateCycle(int pageId, int parentId, CatalogPageType pageType) { int current = parentId; for (int hops = 0; hops < MAX_PARENT_WALK; hops++) { if (current == ROOT_PARENT_ID) return false; if (current == pageId) return true; - CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current); + CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current, pageType); if (parent == null) return false; current = parent.getParentId(); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java index 9fc808b1..05dbbc9e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java @@ -74,13 +74,13 @@ public class CatalogAdminSavePageEvent extends MessageHandler { return; } - CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId); + CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId, pageType); if (parent == null) { this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + parentId)); return; } - if (this.wouldCreateCycle(pageId, parentId)) { + if (this.wouldCreateCycle(pageId, parentId, pageType)) { this.client.sendResponse(new CatalogAdminResultComposer(false, "Refusing to re-parent: that would create a cycle")); return; } @@ -144,18 +144,21 @@ public class CatalogAdminSavePageEvent extends MessageHandler { statement.setInt(15, pageId); } - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId)); + return; + } } this.client.sendResponse(new CatalogAdminResultComposer(true, "Page saved")); } - private boolean wouldCreateCycle(int pageId, int parentId) { + private boolean wouldCreateCycle(int pageId, int parentId, CatalogPageType pageType) { int current = parentId; for (int hops = 0; hops < MAX_PARENT_WALK; hops++) { if (current == ROOT_PARENT_ID) return false; if (current == pageId) return true; - CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current); + CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current, pageType); if (parent == null) return false; current = parent.getParentId(); } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminPageMutationContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminPageMutationContractTest.java new file mode 100644 index 00000000..f97b0d66 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminPageMutationContractTest.java @@ -0,0 +1,57 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CatalogAdminPageMutationContractTest { + private static final Path CREATE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java"); + private static final Path SAVE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java"); + private static final Path MOVE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java"); + private static final Path DELETE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java"); + + @Test + void pageParentChecksStayWithinTheSameCatalogPageType() throws IOException { + String create = Files.readString(CREATE_SOURCE); + String save = Files.readString(SAVE_SOURCE); + String move = Files.readString(MOVE_SOURCE); + + assertTrue(create.contains("getCatalogPage(parentId, pageType)")); + assertTrue(save.contains("getCatalogPage(parentId, pageType)")); + assertTrue(save.contains("getCatalogPage(current, pageType)")); + assertTrue(move.contains("getCatalogPage(newParentId, pageType)")); + assertTrue(move.contains("getCatalogPage(current, pageType)")); + } + + @Test + void movePageValidatesTargetBeforeTogglingVisibilityOrEnabledState() throws IOException { + String move = Files.readString(MOVE_SOURCE); + + int pageLookup = move.indexOf("getCatalogPage(pageId, pageType)"); + int enabledToggle = move.indexOf("SET enabled = IF"); + int visibleToggle = move.indexOf("SET visible = IF"); + + assertTrue(pageLookup >= 0, "move page should load the page before mutating it"); + assertTrue(pageLookup < enabledToggle, "enabled toggle must not run before page existence is checked"); + assertTrue(pageLookup < visibleToggle, "visible toggle must not run before page existence is checked"); + } + + @Test + void pageMutationsReportMissingRowsInsteadOfAlwaysSucceeding() throws IOException { + String save = Files.readString(SAVE_SOURCE); + String move = Files.readString(MOVE_SOURCE); + String delete = Files.readString(DELETE_SOURCE); + + assertTrue(save.contains("statement.executeUpdate() == 0")); + assertTrue(move.contains("statement.executeUpdate() == 0")); + assertTrue(delete.contains("statement.executeUpdate() == 0")); + } +} From 14a590235cbfcc0b707be7fe43570d457915d9ca Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 18:08:50 +0200 Subject: [PATCH 17/33] fix(console): install jansi for forced ansi startup --- .../src/main/java/com/eu/habbo/Emulator.java | 41 +++++++++++++------ .../eu/habbo/EmulatorStartupConsoleTest.java | 14 +++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 26f113ce..caa28594 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -19,6 +19,7 @@ import com.eu.habbo.plugin.events.emulator.EmulatorStoppedEvent; import com.eu.habbo.threading.ThreadPooling; import com.eu.habbo.util.imager.badges.BadgeImager; import com.eu.habbo.util.logback.ConsoleStyle; +import org.fusesource.jansi.AnsiConsole; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -110,14 +111,12 @@ public final class Emulator { public static void main(String[] args) throws Exception { try { - if (OS_NAME.startsWith("Windows") && !CLASS_PATH.contains("idea_rt.jar")) { - ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - ConsoleAppender appender = (ConsoleAppender) root.getAppender("Console"); - - appender.stop(); - appender.setWithJansi(true); - appender.start(); - } + boolean styledConsole = shouldStyleConsole( + System.getenv(), + System.console() != null, + OS_NAME, + System.getProperty("habbo.console.style", "auto")); + configureAnsiConsole(styledConsole); Locale.setDefault(Locale.of("en")); setBuild(); @@ -125,11 +124,7 @@ public final class Emulator { ConsoleCommand.load(); Emulator.logging = new Logging(); - System.out.println(startupHero(shouldStyleConsole( - System.getenv(), - System.console() != null, - OS_NAME, - System.getProperty("habbo.console.style", "auto")))); + System.out.println(startupHero(styledConsole)); long startTime = System.nanoTime(); @@ -372,6 +367,26 @@ public final class Emulator { return ConsoleStyle.isEnabled(environment, interactiveConsole, osName, styleProperty); } + static void configureAnsiConsole(boolean styledConsole) { + if (!styledConsole || !OS_NAME.startsWith("Windows") || CLASS_PATH.contains("idea_rt.jar")) { + return; + } + + try { + AnsiConsole.systemInstall(); + + ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + ConsoleAppender appender = (ConsoleAppender) root.getAppender("Console"); + if (appender != null) { + appender.stop(); + appender.setWithJansi(true); + appender.start(); + } + } catch (Throwable e) { + LOGGER.debug("Unable to install Jansi console bridge; continuing with raw console output.", e); + } + } + private static String fit(String value, int width) { String safe = value == null ? "" : value; if (safe.length() > width) { diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java index 3f61b09b..aa496cbe 100644 --- a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -3,6 +3,8 @@ package com.eu.habbo; import org.junit.jupiter.api.Test; import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -55,4 +57,16 @@ class EmulatorStartupConsoleTest { "Windows 11", "plain")); } + + @Test + void windowsAnsiModeInstallsJansiBeforePrintingStartupHero() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); + + assertTrue(source.contains("AnsiConsole.systemInstall()"), + "forced ANSI mode must install the Jansi bridge for Windows CMD/System.out"); + assertTrue(source.contains("configureAnsiConsole(styledConsole)"), + "console bridge must be configured before startupHero is printed"); + assertTrue(source.indexOf("configureAnsiConsole(styledConsole)") < source.indexOf("startupHero(styledConsole)"), + "Jansi must be installed before writing ANSI startup output"); + } } From 61972dafa43a0eb2e5fa7efaafa6b3fa7a2fba74 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 18:12:55 +0200 Subject: [PATCH 18/33] fix(config): register gui enabled default --- Emulator/src/main/java/com/eu/habbo/Emulator.java | 1 + .../java/com/eu/habbo/EmulatorStartupConsoleTest.java | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index caa28594..ecae8893 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -158,6 +158,7 @@ public final class Emulator { Emulator.config.register("camera.price.points.type", "5"); Emulator.config.register("camera.render.delay", "5"); Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); + Emulator.config.register("gui.enabled", "1"); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(startupCard(hotelTimezoneId)); Emulator.texts.register("camera.permission", "You don't have permission to use the camera!"); diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java index aa496cbe..5ba1bc83 100644 --- a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -69,4 +69,14 @@ class EmulatorStartupConsoleTest { assertTrue(source.indexOf("configureAnsiConsole(styledConsole)") < source.indexOf("startupHero(styledConsole)"), "Jansi must be installed before writing ANSI startup output"); } + + @Test + void registersGuiEnabledBeforeReadingIt() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); + + assertTrue(source.contains("register(\"gui.enabled\", \"1\")"), + "gui.enabled must be registered so the default GUI toggle does not log missing config errors"); + assertTrue(source.indexOf("register(\"gui.enabled\", \"1\")") < source.indexOf("getBoolean(\"gui.enabled\", true)"), + "gui.enabled must be registered before it is read"); + } } From c6e43c6d55978074a0bb77e201cbe3c1e8accf3a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 18:18:20 +0200 Subject: [PATCH 19/33] fix(config): keep gui disabled by default --- Emulator/src/main/java/com/eu/habbo/Emulator.java | 4 ++-- .../test/java/com/eu/habbo/EmulatorStartupConsoleTest.java | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index ecae8893..8fbc94cd 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -158,7 +158,7 @@ public final class Emulator { Emulator.config.register("camera.price.points.type", "5"); Emulator.config.register("camera.render.delay", "5"); Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); - Emulator.config.register("gui.enabled", "1"); + Emulator.config.register("gui.enabled", "0"); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(startupCard(hotelTimezoneId)); Emulator.texts.register("camera.permission", "You don't have permission to use the camera!"); @@ -198,7 +198,7 @@ public final class Emulator { Emulator.isReady = true; Emulator.timeStarted = getIntUnixTimestamp(); - if (Emulator.getConfig().getBoolean("gui.enabled", true)) { + if (Emulator.getConfig().getBoolean("gui.enabled", false)) { EmulatorDashboard.launch(); } diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java index 5ba1bc83..3a4f6499 100644 --- a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -74,9 +74,9 @@ class EmulatorStartupConsoleTest { void registersGuiEnabledBeforeReadingIt() throws Exception { String source = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); - assertTrue(source.contains("register(\"gui.enabled\", \"1\")"), - "gui.enabled must be registered so the default GUI toggle does not log missing config errors"); - assertTrue(source.indexOf("register(\"gui.enabled\", \"1\")") < source.indexOf("getBoolean(\"gui.enabled\", true)"), + assertTrue(source.contains("register(\"gui.enabled\", \"0\")"), + "gui.enabled must be registered disabled by default so it does not log missing config errors or start the UI unexpectedly"); + assertTrue(source.indexOf("register(\"gui.enabled\", \"0\")") < source.indexOf("getBoolean(\"gui.enabled\", false)"), "gui.enabled must be registered before it is read"); } } From 1a03b8f3a9f21c060a158ef280da0aa1ef3247b5 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 19:00:22 +0200 Subject: [PATCH 20/33] fix(gui): require explicit dashboard autostart --- Emulator/src/main/java/com/eu/habbo/Emulator.java | 7 ++++++- .../java/com/eu/habbo/EmulatorStartupConsoleTest.java | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 8fbc94cd..720b6af0 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -159,6 +159,7 @@ public final class Emulator { Emulator.config.register("camera.render.delay", "5"); Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); Emulator.config.register("gui.enabled", "0"); + Emulator.config.register("gui.autostart.enabled", "0"); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(startupCard(hotelTimezoneId)); Emulator.texts.register("camera.permission", "You don't have permission to use the camera!"); @@ -198,7 +199,7 @@ public final class Emulator { Emulator.isReady = true; Emulator.timeStarted = getIntUnixTimestamp(); - if (Emulator.getConfig().getBoolean("gui.enabled", false)) { + if (shouldLaunchGui()) { EmulatorDashboard.launch(); } @@ -388,6 +389,10 @@ public final class Emulator { } } + static boolean shouldLaunchGui() { + return Emulator.getConfig() != null && Emulator.getConfig().getBoolean("gui.autostart.enabled", false); + } + private static String fit(String value, int width) { String safe = value == null ? "" : value; if (safe.length() > width) { diff --git a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java index 3a4f6499..c748f081 100644 --- a/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -76,7 +76,13 @@ class EmulatorStartupConsoleTest { assertTrue(source.contains("register(\"gui.enabled\", \"0\")"), "gui.enabled must be registered disabled by default so it does not log missing config errors or start the UI unexpectedly"); - assertTrue(source.indexOf("register(\"gui.enabled\", \"0\")") < source.indexOf("getBoolean(\"gui.enabled\", false)"), - "gui.enabled must be registered before it is read"); + assertTrue(source.contains("register(\"gui.autostart.enabled\", \"0\")"), + "GUI autostart must use a new disabled-by-default key so old gui.enabled=1 settings do not launch the current UI"); + assertTrue(source.indexOf("register(\"gui.autostart.enabled\", \"0\")") < source.indexOf("shouldLaunchGui()"), + "GUI autostart must be registered before the launch decision"); + assertFalse(source.contains("getBoolean(\"gui.enabled\", true)"), + "GUI must not use a true fallback"); + assertFalse(source.contains("getBoolean(\"gui.enabled\", false)"), + "legacy gui.enabled must not control startup anymore"); } } From 39c6e2409756e117c6663d31e3f2e8f9bf2ed483 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 20:39:51 +0200 Subject: [PATCH 21/33] fix(items): persist clothing grants before redeem Redeeming clothing furniture now inserts the wardrobe grant before removing/deleting the voucher furniture. If the DB insert fails, the item remains in the room and the in-memory wardrobe is not updated. Tests: mvn -Dtest=RedeemClothingContractTest test; mvn -DskipTests package --- .../rooms/items/RedeemClothingEvent.java | 23 ++++++++++----- .../items/RedeemClothingContractTest.java | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java index 496d521d..2ab95a0d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java @@ -36,6 +36,10 @@ public class RedeemClothingEvent extends MessageHandler { if (clothing != null) { if (!this.client.getHabbo().getInventory().getWardrobeComponent().getClothing().contains(clothing.id)) { + if (!this.grantClothing(clothing.id)) { + return; + } + item.setRoomId(0); RoomTile tile = this.client.getHabbo().getHabboInfo().getCurrentRoom().getLayout().getTile(item.getX(), item.getY()); this.client.getHabbo().getHabboInfo().getCurrentRoom().removeHabboItem(item); @@ -44,14 +48,6 @@ public class RedeemClothingEvent extends MessageHandler { this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RemoveFloorItemComposer(item, true).compose()); Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) { - statement.setInt(1, this.client.getHabbo().getHabboInfo().getId()); - statement.setInt(2, clothing.id); - statement.execute(); - } catch (SQLException e) { - LOGGER.error("Caught SQL exception", e); - } - this.client.getHabbo().getInventory().getWardrobeComponent().getClothing().add(clothing.id); this.client.getHabbo().getInventory().getWardrobeComponent().getClothingSets().addAll(clothing.setId); this.client.sendResponse(new UserClothesComposer(this.client.getHabbo())); @@ -67,4 +63,15 @@ public class RedeemClothingEvent extends MessageHandler { } } } + + private boolean grantClothing(int clothingId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) { + statement.setInt(1, this.client.getHabbo().getHabboInfo().getId()); + statement.setInt(2, clothingId); + return statement.executeUpdate() > 0; + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + return false; + } + } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java new file mode 100644 index 00000000..fcbf35c7 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java @@ -0,0 +1,29 @@ +package com.eu.habbo.messages.incoming.rooms.items; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class RedeemClothingContractTest { + private static String source() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java")); + } + + @Test + void clothingIsGrantedBeforeVoucherFurnitureIsConsumed() throws Exception { + String source = source(); + + int grantCall = source.indexOf("grantClothing("); + int roomRemoval = source.indexOf("removeHabboItem(item)"); + int deleteItem = source.indexOf("new QueryDeleteHabboItem(item.getId())"); + + assertTrue(source.contains("private boolean grantClothing(int clothingId)"), + "clothing DB insert should report whether the grant succeeded"); + assertTrue(grantCall > -1, "redeem path should call grantClothing before consuming the item"); + assertTrue(grantCall < roomRemoval, "room item must not be removed before the clothing grant succeeds"); + assertTrue(grantCall < deleteItem, "voucher furniture must not be deleted before the clothing grant succeeds"); + } +} From 7ba0029ba813b36c7088ccf801397abbf8059969 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 20:48:48 +0200 Subject: [PATCH 22/33] fix(bots): preserve owner on pickup Room owners can remove bots from their room, but picking up another user's bot must return it to the original owner instead of transferring ownership to the picker. Tests: mvn -Dtest=BotPickupOwnershipContractTest test; mvn -DskipTests package --- .../eu/habbo/habbohotel/bots/BotManager.java | 20 ++++++++++--- .../bots/BotPickupOwnershipContractTest.java | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java index 7fd3ee93..7d5ea500 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java @@ -179,9 +179,13 @@ public class BotManager { } public void pickUpBot(Bot bot, Habbo habbo) { - HabboInfo receiverInfo = habbo == null ? Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId()) : habbo.getHabboInfo(); - if (bot != null) { + HabboInfo receiverInfo = resolvePickupReceiver(bot, habbo); + Room botRoom = bot.getRoom(); + if (receiverInfo == null || botRoom == null) { + return; + } + BotPickUpEvent pickedUpEvent = new BotPickUpEvent(bot, habbo); Emulator.getPluginManager().fireEvent(pickedUpEvent); @@ -198,8 +202,8 @@ public class BotManager { return; } - bot.onPickUp(habbo, receiverInfo.getCurrentRoom()); - receiverInfo.getCurrentRoom().removeBot(bot); + bot.onPickUp(habbo, botRoom); + botRoom.removeBot(bot); bot.stopFollowingHabbo(); bot.setOwnerId(receiverInfo.getId()); bot.setOwnerName(receiverInfo.getUsername()); @@ -215,6 +219,14 @@ public class BotManager { } } + private HabboInfo resolvePickupReceiver(Bot bot, Habbo picker) { + if (picker != null && bot.getOwnerId() == picker.getHabboInfo().getId()) { + return picker.getHabboInfo(); + } + + return Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId()); + } + public Bot loadBot(ResultSet set) { try { String type = set.getString("type"); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java new file mode 100644 index 00000000..43295c39 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java @@ -0,0 +1,30 @@ +package com.eu.habbo.habbohotel.bots; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class BotPickupOwnershipContractTest { + private static String source() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java")); + } + + @Test + void roomOwnerPickupReturnsBotToOriginalOwner() throws Exception { + String source = source(); + + assertTrue(source.contains("HabboInfo receiverInfo = resolvePickupReceiver(bot, habbo);"), + "bot pickup should resolve the receiver without blindly using the picker"); + assertTrue(source.contains("private HabboInfo resolvePickupReceiver(Bot bot, Habbo picker)"), + "bot pickup receiver logic should be centralized"); + assertTrue(source.contains("return Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId());"), + "when a room owner picks up someone else's bot, it should return to the original bot owner"); + assertTrue(source.contains("Room botRoom = bot.getRoom();"), + "pickup should remove the bot from the bot's current room, not the receiver's current room"); + assertTrue(source.contains("botRoom.removeBot(bot);"), + "bot removal should work even when the original owner is offline"); + } +} From 478c4c70b885813eb54f3d36e94cfd1fedc58709 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 19:25:16 +0200 Subject: [PATCH 23/33] fix(trading): prevent duplicate active trades Guard RoomTradeManager.startTrade while holding the activeTrades lock so concurrent trade starts cannot register the same participant in multiple active trades before room status updates settle. Add a contract test covering the lock-scoped participant guard and keep the existing trade safety tests green. --- .../habbohotel/rooms/RoomTradeManager.java | 19 +++++++++++- .../rooms/RoomTradeManagerContractTest.java | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeManagerContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeManager.java index 6b4790ec..b7c77efe 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeManager.java @@ -19,8 +19,13 @@ public class RoomTradeManager { * Starts a trade between two users. */ public void startTrade(Habbo userOne, Habbo userTwo) { - RoomTrade trade = new RoomTrade(userOne, userTwo, this.room); + RoomTrade trade; synchronized (this.activeTrades) { + if (this.hasActiveTrade(userOne) || this.hasActiveTrade(userTwo)) { + return; + } + + trade = new RoomTrade(userOne, userTwo, this.room); this.activeTrades.add(trade); } @@ -58,4 +63,16 @@ public class RoomTradeManager { public THashSet getActiveTrades() { return this.activeTrades; } + + private boolean hasActiveTrade(Habbo user) { + for (RoomTrade trade : this.activeTrades) { + for (RoomTradeUser habbo : trade.getRoomTradeUsers()) { + if (habbo.getHabbo() == user) { + return true; + } + } + } + + return false; + } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeManagerContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeManagerContractTest.java new file mode 100644 index 00000000..07ec566d --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/rooms/RoomTradeManagerContractTest.java @@ -0,0 +1,30 @@ +package com.eu.habbo.habbohotel.rooms; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class RoomTradeManagerContractTest { + private static String roomTradeManagerSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeManager.java")); + } + + @Test + void startTradeRejectsParticipantsAlreadyInActiveTradeInsideLock() throws Exception { + String source = roomTradeManagerSource(); + int synchronizedBlock = source.indexOf("synchronized (this.activeTrades)"); + int activeGuard = source.indexOf("hasActiveTrade(userOne) || this.hasActiveTrade(userTwo)"); + int addTrade = source.indexOf("this.activeTrades.add(trade)"); + + assertTrue(synchronizedBlock > -1, "RoomTradeManager.startTrade must lock activeTrades before mutation"); + assertTrue(activeGuard > synchronizedBlock, + "startTrade must check both participants for an existing active trade while holding the activeTrades lock"); + assertTrue(activeGuard < addTrade, + "duplicate participant guard must run before a new RoomTrade is added"); + assertTrue(source.contains("private boolean hasActiveTrade(Habbo user)"), + "active trade lookup should be reusable under the same activeTrades lock"); + } +} From a92feb2ef0d043f30d66d842f56d04d9e96c1950 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 15:48:36 +0200 Subject: [PATCH 24/33] fix(commands): quiet optional descriptions --- .../java/com/eu/habbo/core/TextsManager.java | 4 +++ .../habbohotel/commands/CommandsCommand.java | 2 +- .../commands/AvailableCommandsComposer.java | 4 +-- .../core/CommandTextLookupContractTest.java | 35 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/core/CommandTextLookupContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/core/TextsManager.java b/Emulator/src/main/java/com/eu/habbo/core/TextsManager.java index 5c27eedb..02f634e1 100644 --- a/Emulator/src/main/java/com/eu/habbo/core/TextsManager.java +++ b/Emulator/src/main/java/com/eu/habbo/core/TextsManager.java @@ -52,6 +52,10 @@ public class TextsManager { return this.texts.getProperty(key, defaultValue); } + public String getValueQuietly(String key, String defaultValue) { + return this.texts.getProperty(key, defaultValue); + } + public boolean getBoolean(String key) { return this.getBoolean(key, false); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandsCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandsCommand.java index 0d0e0a48..6d9f80c8 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandsCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandsCommand.java @@ -18,7 +18,7 @@ public class CommandsCommand extends Command { for (Command c : commands) { String textKey = "commands.description." + c.permission; - String commandText = Emulator.getTexts().getValue(textKey, ""); + String commandText = Emulator.getTexts().getValueQuietly(textKey, ""); String commandLine = ":" + c.keys[0]; String description = ""; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/commands/AvailableCommandsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/commands/AvailableCommandsComposer.java index 0f8e00f5..dab605b5 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/commands/AvailableCommandsComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/commands/AvailableCommandsComposer.java @@ -23,10 +23,10 @@ public class AvailableCommandsComposer extends MessageComposer { for (Command cmd : this.commands) { this.response.appendString(cmd.keys[0]); this.response.appendString( - Emulator.getTexts().getValue("commands.description." + cmd.permission, cmd.permission) + Emulator.getTexts().getValueQuietly("commands.description." + cmd.permission, cmd.permission) ); } return this.response; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/core/CommandTextLookupContractTest.java b/Emulator/src/test/java/com/eu/habbo/core/CommandTextLookupContractTest.java new file mode 100644 index 00000000..a688f8ce --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/core/CommandTextLookupContractTest.java @@ -0,0 +1,35 @@ +package com.eu.habbo.core; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class CommandTextLookupContractTest { + private static final Path TEXTS_MANAGER = Path.of("src/main/java/com/eu/habbo/core/TextsManager.java"); + private static final Path COMMANDS_COMMAND = Path.of("src/main/java/com/eu/habbo/habbohotel/commands/CommandsCommand.java"); + private static final Path AVAILABLE_COMMANDS_COMPOSER = Path.of( + "src/main/java/com/eu/habbo/messages/outgoing/commands/AvailableCommandsComposer.java"); + + @Test + void textsManagerExposesQuietFallbackLookupForOptionalTexts() throws IOException { + String source = Files.readString(TEXTS_MANAGER); + + assertTrue(source.contains("public String getValueQuietly(String key, String defaultValue)")); + assertTrue(source.contains("return this.texts.getProperty(key, defaultValue);")); + } + + @Test + void commandListsUseQuietDescriptionLookups() throws IOException { + String commandsCommand = Files.readString(COMMANDS_COMMAND); + String availableCommandsComposer = Files.readString(AVAILABLE_COMMANDS_COMPOSER); + + assertTrue(commandsCommand.contains("getValueQuietly(textKey, \"\")"), + ":commands should not log an error when an optional command description is missing"); + assertTrue(availableCommandsComposer.contains("getValueQuietly(\"commands.description.\" + cmd.permission, cmd.permission)"), + "available commands composer should not log an error when an optional command description is missing"); + } +} From 8672c2d0ea43ad0e816e5bd117424293dcf64b73 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:08:20 +0200 Subject: [PATCH 25/33] fix(catalog): validate admin offer payloads --- .../CatalogAdminCreateOfferEvent.java | 48 ++++--- .../CatalogAdminOfferPayload.java | 125 ++++++++++++++++++ .../CatalogAdminSaveOfferEvent.java | 57 +++++--- ...CatalogAdminOfferMutationContractTest.java | 43 ++++++ .../CatalogAdminOfferPayloadTest.java | 41 ++++++ 5 files changed, 278 insertions(+), 36 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java index 380a3e07..59da4f9b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java @@ -35,33 +35,45 @@ public class CatalogAdminCreateOfferEvent extends MessageHandler { int orderNumber = this.packet.readInt(); CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString()); + CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits, + costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack, + orderNumber, pageType); + if (payload == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload")); + return; + } + + if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId)); + return; + } + int newId = -1; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( - (pageType == CatalogPageType.BUILDER) + (payload.pageType == CatalogPageType.BUILDER) ? "INSERT INTO catalog_items_bc (page_id, item_ids, catalog_name, order_number, extradata) VALUES (?, ?, ?, ?, ?)" : "INSERT INTO catalog_items (page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, club_only, extradata, have_offer, offer_id, limited_stack, order_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS)) { - String cleanItemIds = (itemIds == null || itemIds.trim().isEmpty()) ? "0" : itemIds.trim(); - statement.setInt(1, pageId); - statement.setString(2, cleanItemIds); - statement.setString(3, catalogName); + statement.setInt(1, payload.pageId); + statement.setString(2, payload.itemIds); + statement.setString(3, payload.catalogName); - if (pageType == CatalogPageType.BUILDER) { - statement.setInt(4, orderNumber); - statement.setString(5, extradata); + if (payload.pageType == CatalogPageType.BUILDER) { + statement.setInt(4, payload.orderNumber); + statement.setString(5, payload.extradata); } else { - statement.setInt(4, costCredits); - statement.setInt(5, costPoints); - statement.setInt(6, pointsType); - statement.setInt(7, amount); - statement.setString(8, clubOnly == 1 ? "1" : "0"); - statement.setString(9, extradata); - statement.setString(10, haveOffer ? "1" : "0"); - statement.setInt(11, offerIdGroup); - statement.setInt(12, limitedStack); - statement.setInt(13, orderNumber); + statement.setInt(4, payload.costCredits); + statement.setInt(5, payload.costPoints); + statement.setInt(6, payload.pointsType); + statement.setInt(7, payload.amount); + statement.setString(8, payload.clubOnly == 1 ? "1" : "0"); + statement.setString(9, payload.extradata); + statement.setString(10, payload.haveOffer ? "1" : "0"); + statement.setInt(11, payload.offerIdGroup); + statement.setInt(12, payload.limitedStack); + statement.setInt(13, payload.orderNumber); } statement.execute(); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java new file mode 100644 index 00000000..43073036 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java @@ -0,0 +1,125 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import com.eu.habbo.habbohotel.catalog.CatalogPageType; + +final class CatalogAdminOfferPayload { + private static final int MAX_ITEM_IDS_LENGTH = 512; + private static final int MAX_ITEM_IDS = 100; + private static final int MAX_CATALOG_NAME_LENGTH = 128; + private static final int MAX_EXTRADATA_LENGTH = 1024; + private static final int MAX_CURRENCY_VALUE = 1_000_000_000; + private static final int MAX_AMOUNT = 10_000; + private static final int MAX_POINTS_TYPE = 10_000; + private static final int MAX_ORDER_NUMBER = 1_000_000; + private static final int MAX_LIMITED_STACK = 1_000_000; + + final int pageId; + final String itemIds; + final String catalogName; + final int costCredits; + final int costPoints; + final int pointsType; + final int amount; + final int clubOnly; + final String extradata; + final boolean haveOffer; + final int offerIdGroup; + final int limitedStack; + final int orderNumber; + final CatalogPageType pageType; + + private CatalogAdminOfferPayload(int pageId, String itemIds, String catalogName, int costCredits, int costPoints, + int pointsType, int amount, int clubOnly, String extradata, boolean haveOffer, + int offerIdGroup, int limitedStack, int orderNumber, CatalogPageType pageType) { + this.pageId = pageId; + this.itemIds = itemIds; + this.catalogName = catalogName; + this.costCredits = costCredits; + this.costPoints = costPoints; + this.pointsType = pointsType; + this.amount = amount; + this.clubOnly = clubOnly; + this.extradata = extradata; + this.haveOffer = haveOffer; + this.offerIdGroup = offerIdGroup; + this.limitedStack = limitedStack; + this.orderNumber = orderNumber; + this.pageType = pageType; + } + + static CatalogAdminOfferPayload validate(int pageId, String itemIds, String catalogName, int costCredits, + int costPoints, int pointsType, int amount, int clubOnly, + String extradata, boolean haveOffer, int offerIdGroup, + int limitedStack, int orderNumber, CatalogPageType pageType) { + String cleanItemIds = normalizeItemIds(itemIds); + String cleanCatalogName = clamp(catalogName, MAX_CATALOG_NAME_LENGTH); + String cleanExtradata = clamp(extradata, MAX_EXTRADATA_LENGTH); + + if (pageId <= 0 + || cleanItemIds == null + || cleanCatalogName.isBlank() + || !isInRange(orderNumber, 0, MAX_ORDER_NUMBER)) { + return null; + } + + if (pageType != CatalogPageType.BUILDER) { + if (!isInRange(costCredits, 0, MAX_CURRENCY_VALUE) + || !isInRange(costPoints, 0, MAX_CURRENCY_VALUE) + || !isInRange(pointsType, 0, MAX_POINTS_TYPE) + || !isInRange(amount, 1, MAX_AMOUNT) + || !isInRange(clubOnly, 0, 1) + || offerIdGroup < 0 + || !isInRange(limitedStack, 0, MAX_LIMITED_STACK)) { + return null; + } + } + + return new CatalogAdminOfferPayload(pageId, cleanItemIds, cleanCatalogName, costCredits, costPoints, + pointsType, amount, clubOnly, cleanExtradata, haveOffer, offerIdGroup, limitedStack, orderNumber, + pageType); + } + + private static String normalizeItemIds(String value) { + if (value == null || value.trim().isEmpty()) { + return "0"; + } + + String clean = value.trim(); + if (clean.length() > MAX_ITEM_IDS_LENGTH) { + return null; + } + + String[] parts = clean.split(","); + if (parts.length == 0 || parts.length > MAX_ITEM_IDS) { + return null; + } + + for (String part : parts) { + if (part.isBlank()) { + return null; + } + + try { + if (Integer.parseInt(part.trim()) < 0) { + return null; + } + } catch (NumberFormatException e) { + return null; + } + } + + return clean.replaceAll("\\s+", ""); + } + + private static boolean isInRange(int value, int min, int max) { + return value >= min && value <= max; + } + + private static String clamp(String value, int maxLength) { + if (value == null) { + return ""; + } + + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java index 1a5aff1a..816d31c5 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java @@ -34,10 +34,28 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler { int orderNumber = this.packet.readInt(); CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString()); + if (offerId <= 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer id")); + return; + } + + CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits, + costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack, + orderNumber, pageType); + if (payload == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload")); + return; + } + + if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId)); + return; + } + boolean updateItemIds = itemIds != null && !itemIds.trim().isEmpty(); String sql; - if (pageType == CatalogPageType.BUILDER) { + if (payload.pageType == CatalogPageType.BUILDER) { sql = updateItemIds ? "UPDATE catalog_items_bc SET page_id = ?, item_ids = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?" : "UPDATE catalog_items_bc SET page_id = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?"; @@ -50,30 +68,33 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { int idx = 1; - statement.setInt(idx++, pageId); + statement.setInt(idx++, payload.pageId); if (updateItemIds) { - statement.setString(idx++, itemIds.trim()); + statement.setString(idx++, payload.itemIds); } - statement.setString(idx++, catalogName); + statement.setString(idx++, payload.catalogName); - if (pageType == CatalogPageType.BUILDER) { - statement.setInt(idx++, orderNumber); - statement.setString(idx++, extradata); + if (payload.pageType == CatalogPageType.BUILDER) { + statement.setInt(idx++, payload.orderNumber); + statement.setString(idx++, payload.extradata); statement.setInt(idx, offerId); } else { - statement.setInt(idx++, costCredits); - statement.setInt(idx++, costPoints); - statement.setInt(idx++, pointsType); - statement.setInt(idx++, amount); - statement.setString(idx++, clubOnly == 1 ? "1" : "0"); - statement.setString(idx++, extradata); - statement.setString(idx++, haveOffer ? "1" : "0"); - statement.setInt(idx++, offerIdGroup); - statement.setInt(idx++, limitedStack); - statement.setInt(idx++, orderNumber); + statement.setInt(idx++, payload.costCredits); + statement.setInt(idx++, payload.costPoints); + statement.setInt(idx++, payload.pointsType); + statement.setInt(idx++, payload.amount); + statement.setString(idx++, payload.clubOnly == 1 ? "1" : "0"); + statement.setString(idx++, payload.extradata); + statement.setString(idx++, payload.haveOffer ? "1" : "0"); + statement.setInt(idx++, payload.offerIdGroup); + statement.setInt(idx++, payload.limitedStack); + statement.setInt(idx++, payload.orderNumber); statement.setInt(idx, offerId); } - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Offer not found: " + offerId)); + return; + } } this.client.sendResponse(new CatalogAdminResultComposer(true, "Offer saved")); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java new file mode 100644 index 00000000..901922ca --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java @@ -0,0 +1,43 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class CatalogAdminOfferMutationContractTest { + private static final Path CREATE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java"); + private static final Path SAVE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java"); + + @Test + void createAndSaveValidatePayloadAndTargetPageBeforeWriting() throws IOException { + String create = Files.readString(CREATE_SOURCE); + String save = Files.readString(SAVE_SOURCE); + + assertTrue(create.contains("CatalogAdminOfferPayload.validate(")); + assertTrue(save.contains("CatalogAdminOfferPayload.validate(")); + assertTrue(create.contains("getCatalogPage(payload.pageId, payload.pageType) == null")); + assertTrue(save.contains("getCatalogPage(payload.pageId, payload.pageType) == null")); + + int createValidation = create.indexOf("CatalogAdminOfferPayload.validate("); + int createInsert = create.indexOf("INSERT INTO catalog_items"); + int saveValidation = save.indexOf("CatalogAdminOfferPayload.validate("); + int saveUpdate = save.indexOf("UPDATE catalog_items"); + + assertTrue(createValidation < createInsert, "create offer should validate before insert SQL is prepared"); + assertTrue(saveValidation < saveUpdate, "save offer should validate before update SQL is prepared"); + } + + @Test + void saveOfferReportsMissingRowsInsteadOfAlwaysSucceeding() throws IOException { + String save = Files.readString(SAVE_SOURCE); + + assertTrue(save.contains("statement.executeUpdate() == 0")); + assertTrue(save.contains("Offer not found: ")); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java new file mode 100644 index 00000000..8b2d71b4 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java @@ -0,0 +1,41 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.eu.habbo.habbohotel.catalog.CatalogPageType; +import org.junit.jupiter.api.Test; + +class CatalogAdminOfferPayloadTest { + @Test + void acceptsAndNormalizesValidOfferPayload() { + CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate( + 42, "1, 2,3", "Rare Chair", 100, 5, 0, 1, 0, + "extra", true, 0, 0, 10, CatalogPageType.NORMAL); + + assertNotNull(payload); + assertEquals("1,2,3", payload.itemIds); + assertEquals("Rare Chair", payload.catalogName); + } + + @Test + void rejectsInvalidItemIdsAndNegativeEconomyValues() { + assertNull(CatalogAdminOfferPayload.validate(42, "1,abc", "Name", 0, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.NORMAL)); + assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", -1, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.NORMAL)); + assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", 0, 0, 0, 0, 0, + "", false, 0, 0, 0, CatalogPageType.NORMAL)); + } + + @Test + void builderOffersStillRequireSafeCommonFields() { + assertNotNull(CatalogAdminOfferPayload.validate(42, "", "BC Offer", -1, -1, -1, -1, -1, + "", false, -1, -1, 0, CatalogPageType.BUILDER)); + assertNull(CatalogAdminOfferPayload.validate(0, "1", "BC Offer", 0, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.BUILDER)); + assertNull(CatalogAdminOfferPayload.validate(42, "1", "", 0, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.BUILDER)); + } +} From 8db6281cc80ed81d04f609e4017fd919c4b89ee3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 20:19:45 +0200 Subject: [PATCH 26/33] fix(guilds): only accept pending memberships Guard the guild acceptance update with level_id = REQUESTED so a stale or concurrent accept cannot promote a membership row that has already changed state. Tests: mvn '-Dtest=GuildManagerMembershipContractTest,GuildMembershipManagementContractTest,GuildMembershipRequestContractTest' test --- .../habbo/habbohotel/guilds/GuildManager.java | 3 ++- .../GuildManagerMembershipContractTest.java | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildManagerMembershipContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java index b835a5f5..9441fc11 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java @@ -291,11 +291,12 @@ public class GuildManager { } } } else if (!error) { - try (PreparedStatement statement = connection.prepareStatement("UPDATE guilds_members SET level_id = ?, member_since = ? WHERE user_id = ? AND guild_id = ?")) { + try (PreparedStatement statement = connection.prepareStatement("UPDATE guilds_members SET level_id = ?, member_since = ? WHERE user_id = ? AND guild_id = ? AND level_id = ?")) { statement.setInt(1, GuildRank.MEMBER.type); statement.setInt(2, Emulator.getIntUnixTimestamp()); statement.setInt(3, userId); statement.setInt(4, guild.getId()); + statement.setInt(5, GuildRank.REQUESTED.type); statement.execute(); } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildManagerMembershipContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildManagerMembershipContractTest.java new file mode 100644 index 00000000..20a599c1 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/guilds/GuildManagerMembershipContractTest.java @@ -0,0 +1,24 @@ +package com.eu.habbo.habbohotel.guilds; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class GuildManagerMembershipContractTest { + private static String guildManagerSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java")); + } + + @Test + void acceptRequestOnlyPromotesPendingMembershipRows() throws Exception { + String source = guildManagerSource(); + + assertTrue(source.contains("UPDATE guilds_members SET level_id = ?, member_since = ? WHERE user_id = ? AND guild_id = ? AND level_id = ?"), + "accepting a guild request must only promote rows still in REQUESTED state"); + assertTrue(source.contains("statement.setInt(5, GuildRank.REQUESTED.type);"), + "the accept-request update must bind the expected REQUESTED rank guard"); + } +} From aec61064aea93adc49cf3183bc32f370239e146d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:08:17 +0200 Subject: [PATCH 27/33] fix(furnidata): prefer renderer config source Resolve furnidata from the renderer config and asset base before falling back to the legacy items.furnidata.path override. This keeps the emulator aligned with the same furnidata URL the UI/renderer already consume. Keep the legacy path as a compatibility fallback for older installs, but stop exposing absolute furnidata file paths in the startup log. The provider now reports a compact manager-style source label instead. Add coverage proving renderer-config furnidata.url wins over the legacy path when both are present. --- .../items/FurnidataSourceResolver.java | 36 +++++++++++-------- .../items/FurnitureTextProvider.java | 10 ++++-- .../furnieditor/FurniDataManagerTest.java | 29 +++++++++++++++ 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java index 6d1f758f..85707871 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java @@ -36,30 +36,36 @@ public final class FurnidataSourceResolver { public static Source resolve() { try { String override = Emulator.getConfig().getValue("items.furnidata.path", ""); - if (!override.isEmpty()) { - Path p = Paths.get(override); - if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path"); - return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path does not exist"); - } - String rendererConfigPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", ""); String assetBasePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); - if (!rendererConfigPath.isEmpty()) { - Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath.isEmpty() ? null : Paths.get(assetBasePath)); - if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer; - } - - Source fallback = resolveFromAssetBase(assetBasePath); - if (fallback != null) return fallback; - - return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found"); + return resolveConfigured(override, rendererConfigPath, assetBasePath); } catch (Exception e) { LOGGER.warn("FurnidataSourceResolver failed", e); return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "Resolver error"); } } + public static Source resolveConfigured(String legacyOverridePath, String rendererConfigPath, String assetBasePath) { + if (rendererConfigPath != null && !rendererConfigPath.isEmpty()) { + Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath == null || assetBasePath.isEmpty() ? null : Paths.get(assetBasePath)); + if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer; + } + + Source fromAssetBase = resolveFromAssetBase(assetBasePath); + if (fromAssetBase != null && fromAssetBase.ok()) return fromAssetBase; + + if (legacyOverridePath != null && !legacyOverridePath.isEmpty()) { + Path p = Paths.get(legacyOverridePath); + if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path fallback"); + return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path fallback does not exist"); + } + + if (fromAssetBase != null) return fromAssetBase; + + return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found"); + } + public static Source resolveFromRendererConfig(Path rendererConfig, Path assetBase) { try { if (rendererConfig == null || !Files.exists(rendererConfig)) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 6e9c6c92..df333dc7 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -27,6 +27,7 @@ public class FurnitureTextProvider { private final boolean enabled; private volatile Map index = Map.of(); private volatile Path source; + private volatile String sourceDescription = "unknown"; private FurnidataWatcher watcher; public FurnitureTextProvider(boolean enabled) { @@ -47,7 +48,7 @@ public class FurnitureTextProvider { return; } reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read()); - LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source); + LOGGER.info("Furniture Text Provider -> Indexed! ({} names, source: {})", this.index.size(), this.sourceDescription); if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) { if (this.watcher != null) this.watcher.stop(); @@ -88,9 +89,12 @@ public class FurnitureTextProvider { } } - private static Path resolveSource() { + private Path resolveSource() { FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve(); - if (source.ok()) return source.path(); + if (source.ok()) { + this.sourceDescription = source.message(); + return source.path(); + } LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message()); return null; } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java index fa3db546..32770ae3 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java @@ -78,4 +78,33 @@ class FurniDataManagerTest { assertEquals(assetBase.resolve("gamedata").resolve("FurnitureData.json"), source.path()); assertFalse(source.directory()); } + + @Test + void prefersRendererConfigOverLegacyFurnidataPath(@TempDir Path dir) throws Exception { + Path legacy = dir.resolve("legacy").resolve("FurnitureData.json"); + Files.createDirectories(legacy.getParent()); + Files.writeString(legacy, "{}"); + + Path assetBase = dir.resolve("nitro-assets"); + Path rendererSource = assetBase.resolve("gamedata").resolve("FurnitureData.json"); + Files.createDirectories(rendererSource.getParent()); + Files.writeString(rendererSource, "{}"); + + Path rendererConfig = dir.resolve("renderer-config.json"); + Files.writeString(rendererConfig, """ + { + "gamedata.url": "http://localhost:5173/nitro-assets/gamedata", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%" + } + """); + + FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolveConfigured( + legacy.toString(), + rendererConfig.toString(), + assetBase.toString()); + + assertTrue(source.ok()); + assertEquals(rendererSource, source.path()); + assertEquals("renderer-config furnidata.url", source.message()); + } } From 93e5ea15aa3710248e22b2a15cec3c6412fab817 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:35:07 +0200 Subject: [PATCH 28/33] =?UTF-8?q?docs(furni-editor):=20implementation=20pl?= =?UTF-8?q?an=20=E2=80=94=20create=20furnidata=20entry=20if=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-13-furnidata-create-if-missing.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md diff --git a/docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md b/docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md new file mode 100644 index 00000000..631a292d --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-furnidata-create-if-missing.md @@ -0,0 +1,118 @@ +# Furnidata create-if-missing (upsert) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement task-by-task. Steps use checkbox (`- [ ]`). + +**Goal:** Let the Furni Editor create a complete furnidata entry for a furni that has none, by making the existing `FurniEditorUpdateFurnidata` (10046) handler an upsert. + +**Architecture:** Reuse packet 10046 (no renderer changes, no new packet). Emulator: new `FurnidataWriter.create(...)` (JSON5-preserving append) + handler routes "classname missing → create complete entry from `items_base`" + config key. Client: unlock name/desc when the entry is missing and relabel Save to "Create entry". + +**Tech Stack:** Java 21 (Arcturus emulator), Gson/JSON5, React/TS (Nitro-V3 client). + +**Spec:** `docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md` + +**Environment note:** On this machine furnidata is a SINGLE file `Nitro-Files/nitro-assets/gamedata/FurnitureData.json` (`FurnitureTextProvider.isSourceDirectory()==false`). Plan must also handle split-tier (directory) since the code supports it. + +--- + +## File structure + +- Modify: `Emulator/.../habbohotel/items/FurnidataWriter.java` — add `create(...)` + a `CreateResult` enum. +- Create: `Emulator/.../habbohotel/items/FurnidataEntryBuilder.java` — maps an `items_base` row → a furnidata JSON5 object string (floor/wall). +- Modify: `Emulator/.../messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java` — upsert routing. +- Create: `Emulator/src/test/.../FurnidataWriterCreateTest.java` — unit test for create(). +- Modify (client): `ui/src/components/furni-editor/views/FurniEditorEditView.tsx` — unlock + relabel + re-fetch. +- Config: `items.furnidata.create_tier` (default `custom`) read in the handler/writer; documented in the spec. + +--- + +### Task 1: Lock the furnidata field map (investigation, no code) + +**Files:** read-only. + +- [ ] **Step 1:** Read the exact `items_base` columns: `grep -n "items_base" Emulator/.../habbohotel/items/ItemManager.java` then read the `Item` constructor that consumes `SELECT * FROM items_base` (`Item.java`) to list columns (expected: `id`, `sprite_id`, `public_name`, `item_name`, `width`, `length`, `stack_height`, `allow_stack`, `allow_sit`, `allow_walk`, `allow_lay`, `type`, `interaction_type`, …). +- [ ] **Step 2:** Read the renderer floor/wall entry parse to confirm which furnidata fields matter: `renderer/packages/.../FurnitureData.ts` (or wherever `FurnitureDataLoader.parseFloorItems` builds a `FurnitureData`). Note the fields it reads (id, classname, revision, category, name, description, adurl, offerId, buyout, rentOfferId, rentBuyout, bc, excludedDynamic, customParams, specialType, canStandOn, canSitOn, canLayOn, furniLine, environment, rare, + dimensions xdim/ydim). +- [ ] **Step 3:** Write the mapping table into this plan file under Task 3 (replace the TABLE-PENDING marker). Mapping (defaults in parens for fields with no items_base source): + - `id` ← `items_base.sprite_id` ; `classname` ← `items_base.item_name` ; section `roomitemtypes`(floor)/`wallitemtypes`(wall) ← `items_base.type` (`s`/`i`) + - `name` ← submitted name (fallback `public_name`→`item_name`) ; `description` ← submitted desc + - `xdim` ← `width` ; `ydim` ← `length` ; `canstandon` ← `allow_walk` ; `cansiton` ← `allow_sit` ; `canlayon` ← `allow_lay` + - defaults: `revision`(0) `category`("") `defaultdir`(0) `partcolors`({color:[]}) `offerid`(-1) `buyout`(false) `rentofferid`(-1) `rentbuyout`(false) `bc`(false) `excludeddynamic`(false) `customparams`("") `specialtype`(1) `canlayon` as above `furniline`("") `environment`("") `rare`(false) +- [ ] **Step 4:** Commit the locked map: `git commit -am "docs(plan): lock furnidata field map"` + +### Task 2: `FurnidataWriter.create(...)` + unit test (TDD) + +**Files:** Modify `FurnidataWriter.java`; Create `FurnidataWriterCreateTest.java`. + +- [ ] **Step 1: Failing test** — create `FurnidataWriterCreateTest` that: writes a temp single-file furnidata `{ "roomitemtypes": { "furnitype": [ { "id":1, "classname":"old", "name":"Old" } ] }, "wallitemtypes": { "furnitype": [] } }`, calls `writer.create(entryObjectJson5, FurnitureType.FLOOR, /*id*/2, "newcn")`, then reads it back with `FurnidataReader` and asserts BOTH `old` and `newcn` are present, and that the new entry has id 2. + +```java +@Test void createAppendsFloorEntryPreservingExisting() throws Exception { + Path f = Files.createTempFile("furnidata", ".json5"); + Files.writeString(f, "{\n // comment\n \"roomitemtypes\": { \"furnitype\": [ { \"id\": 1, \"classname\": \"old\", \"name\": \"Old\" } ] },\n \"wallitemtypes\": { \"furnitype\": [] }\n}"); + FurnidataWriter w = new FurnidataWriter(f, false, 10_000_000L, 3); + String entry = "{ \"id\": 2, \"classname\": \"newcn\", \"name\": \"New\", \"description\": \"\" }"; + FurnidataWriter.CreateResult r = w.create("newcn", 2, FurnitureType.FLOOR, entry); + assertEquals(FurnidataWriter.CreateResult.CREATED, r); + var entries = new FurnidataReader(f, 10_000_000L).read(); + assertTrue(entries.stream().anyMatch(e -> e.classname().equals("old"))); + assertTrue(entries.stream().anyMatch(e -> e.classname().equals("newcn") && e.id() == 2)); + assertTrue(Files.readString(f).contains("// comment")); // JSON5 comment preserved +} +``` + +- [ ] **Step 2: Run, expect FAIL** (method missing): `cd Emulator && mvn -q -Dtest=FurnidataWriterCreateTest test` → FAIL/compile error. +- [ ] **Step 3: Implement `create()` + `CreateResult`.** Add to `FurnidataWriter`: + - `public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR }` + - `public CreateResult create(String classname, int id, FurnitureType type, String entryObjectJson5)`: + 1. `cn = classname.trim().toLowerCase`. Scan all entries via `FurnidataReader(allFiles).read()`: if any entry has `cn` → return `ALREADY_EXISTS`; if any entry has the same `id` but a different classname → return `ID_COLLISION`. + 2. Resolve target file: single-file → `source`; split-tier → the configured create tier file (passed in via a `Path targetFile` arg OR resolved here from `items.furnidata.create_tier`; the handler passes the resolved tier dir's first file). If none → `NO_TARGET` (or create the file with a shell — see Step 3b). + 3. Section key = `roomitemtypes` (FLOOR) / `wallitemtypes` (WALL). + 4. Read raw; locate `"
"` → its `"furnitype"` → the `[` … `]` array (reuse `matchingClose`/brace helpers, string-aware). Insert the entry object: if array empty → `[ ]`; else insert `, ` before the closing `]` (preserve indentation). If the section/array is absent in the target file, synthesize it (e.g. add `"roomitemtypes": { "furnitype": [ ] }` into the root object). + 5. `backup(target)` + `atomicWrite(target, edited)`; return `CREATED`. Wrap IO in try/catch → `IO_ERROR`. + - Reuse existing `matchingClose`, `lastUnbalancedBrace`, `jsonEscape`, `backup`, `atomicWrite`. +- [ ] **Step 3b:** Add a helper to find the array insertion point: `static int furnitypeArrayClose(String raw, String section)` returning the index of the `]` that closes `
.furnitype`, or -1 if absent. String-aware brace/bracket scan starting from the section key match. +- [ ] **Step 4: Run, expect PASS.** Add a 2nd test for `ALREADY_EXISTS` (create "old") and a 3rd for `ID_COLLISION` (create classname "x" with id 1). `mvn -q -Dtest=FurnidataWriterCreateTest test` → PASS. +- [ ] **Step 5: Commit** `git commit -am "feat(furnidata): FurnidataWriter.create — append new entry (JSON5-preserving)"` + +### Task 3: `FurnidataEntryBuilder` (items_base row → entry JSON5 string) + +**Files:** Create `FurnidataEntryBuilder.java`. + +Mapping table: **(filled by Task 1 Step 3)** + +- [ ] **Step 1:** Implement `static String build(ResultSet itemsBaseRow, String name, String description)` (or take a typed struct) that returns a JSON5 object string with the mapped fields (use `jsonEscape` for strings; booleans/ints inline). Floor vs wall determined by caller; this just emits the object. Keep field order matching existing entries for readability. +- [ ] **Step 2:** Unit test: feed a fake row (or a small struct), assert the output string parses (Gson) and has `id`, `classname`, `name`, `xdim`, `ydim`, `canstandon`. `mvn -q -Dtest=FurnidataEntryBuilderTest test` → PASS. +- [ ] **Step 3: Commit** `git commit -am "feat(furnidata): items_base → furnidata entry builder"` + +### Task 4: Handler upsert — `FurniEditorUpdateFurnidataEvent` + +**Files:** Modify `FurniEditorUpdateFurnidataEvent.java`. + +- [ ] **Step 1:** Before `writer.write(...)` (line ~122), check existence: `boolean exists = provider.getName(classname) != null || furnidataHasClassname(provider, classname)`. (Add a small helper that reads the source via `FurnidataReader` and checks the classname, since `getName` returns null for entries with empty names too.) +- [ ] **Step 2:** If `exists` → keep current `write()` path (audit action `"edit"`). +- [ ] **Step 3:** Else (missing) → resolve the full `items_base` row for `itemId` (extend `classnameForItem` into a `loadItemBaseRow(itemId)` returning sprite_id/type/width/length/flags/public_name + classname). Determine `FurnitureType` from `type`. Build the entry via `FurnidataEntryBuilder.build(row, nameOrPublic, desc)`. Resolve target tier (config `items.furnidata.create_tier`, default `custom`; for single-file the writer ignores it). Call `writer.create(classname, spriteId, type, entryJson5)`. Map `CreateResult` → success/precise error message (`ALREADY_EXISTS`→fall back to edit; `ID_COLLISION`→"id N already used"; etc.). On `CREATED`: same post-steps as edit (`reindexFromSource` + broadcast 10047 + mirror public_name + audit action `"create"`). +- [ ] **Step 4:** Build the jar: `cd Emulator && mvn -q clean package -DskipTests` → BUILD SUCCESS, note the produced `target/Habbo-*.jar`. +- [ ] **Step 5: Commit** `git commit -am "feat(furni-editor): upsert — create furnidata entry when classname missing (10046)"` + +### Task 5: Client — unlock + relabel + re-fetch + +**Files:** Modify `ui/src/components/furni-editor/views/FurniEditorEditView.tsx`. + +- [ ] **Step 1:** Change the `furnidataEditable` memo (line ~240) so a `null` entry no longer hard-locks: when `furniDataEntry === null`, return `true` (editable → will create). Keep the existing classname-mismatch lock for the present-but-mismatched case. +- [ ] **Step 2:** Replace the warning block (lines ~401-405) with an informational note when `furniDataEntry === null`: "No furnidata entry yet — saving will create one." Prefill the name input from `item.publicName` when entry is null and the field is empty. +- [ ] **Step 3:** Relabel the Save button to "Create entry" when `furniDataEntry === null`, else "Save name/desc". +- [ ] **Step 4:** On `FurniEditorResultEvent` success, re-send `FurniEditorDetailComposer(item.id)` so `furniDataEntry` repopulates (verify the success handler already refetches; if not, add it). +- [ ] **Step 5:** `yarn --cwd E:/Users/simol/Desktop/DEV/ui typecheck` → clean. **Commit** on a client branch (NOT mixed with PR #236): `git checkout -b feat/furni-editor-create-missing origin/Dev` first, cherry-pick this file's change, commit `feat(furni-editor): create furnidata entry when missing (upsert Save)`. + +### Task 6: Runtime verification (Chrome handle) + +- [ ] **Step 1:** Restart the emulator with the new jar (the user runs it / `emulatore.bat`). Reload `localhost:5173`. +- [ ] **Step 2:** Open Furni Editor on a furni with NO furnidata entry (the "DB fallback" case). Confirm name/desc now editable + button reads "Create entry". +- [ ] **Step 3:** Type a name, Save. Expect: success result; console shows 10046 sent + 10047 (FurnitureDataReload) broadcast; the furni's name updates live; reopening the editor shows the entry now present (editable normally). +- [ ] **Step 4:** Verify on disk: the new object appears in `Nitro-Files/nitro-assets/gamedata/FurnitureData.json` under the right section, with the mapped fields, and `FurnidataReader` parses the file (no corruption; a `.bak` was made). + +--- + +## Self-review notes +- Spec coverage: upsert trigger (T4/T5), complete entry from items_base (T3), config tier (T4), id=sprite + collision guard (T2/T4), no renderer change (none here), error cases (T2 CreateResult + T4 mapping), tests (T2/T3 unit + T6 runtime). Covered. +- Field map exact column names are locked in Task 1 before any code consumes them (not a placeholder — an explicit investigation task). +- Config key aligned to existing prefix: `items.furnidata.create_tier`. From 2bc4340ec998bf4539a04fcd4649811bd36a5ae0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:59:48 +0200 Subject: [PATCH 29/33] feat(furni-editor): create furnidata entry when missing (upsert on 10046) FurniEditorUpdateFurnidataEvent (10046) was edit-only: FurnidataWriter.write() refuses classnames absent from furnidata, so a furni with no entry showed the DB-fallback name with locked fields and "Classname not found". Make it an upsert: - FurnidataWriter.create(): append a complete entry (JSON5-preserving, atomic + backup) into the matching roomitemtypes/wallitemtypes furnitype array; guards against duplicate classname (ALREADY_EXISTS) and id collision (ID_COLLISION); split-tier writes to items.furnidata.create_tier (default "custom", file created with a shell if absent), single-file writes to the source. - FurnidataEntryBuilder: build the complete entry from the item's items_base row (id = sprite id, classname, type-driven section, xdim/ydim, canstandon/ cansiton/canlayon, name/desc, sane defaults matching existing entries). - Handler: on write()==false, load the Item, build + create the entry, map CreateResult to a precise message; then the existing reindex + 10047 broadcast + public_name mirror run for both paths; audit action is "create" vs "edit". No renderer change, no new packet. Pairs with the client unlocking name/desc when the entry is missing (separate Nitro-V3 change). --- .../items/FurnidataEntryBuilder.java | 52 +++++++++++ .../habbohotel/items/FurnidataWriter.java | 92 +++++++++++++++++++ .../FurniEditorUpdateFurnidataEvent.java | 38 +++++++- 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java new file mode 100644 index 00000000..53f9b626 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntryBuilder.java @@ -0,0 +1,52 @@ +package com.eu.habbo.habbohotel.items; + +/** + * Builds a complete furnidata entry object (single-line JSON5) from an {@link Item} + * (its items_base row) plus a display name/description. Used by the Furni Editor + * upsert path when a furni has no furnidata entry yet. Field shape mirrors the + * hotel's existing furnidata entries; {@code id} is the item's sprite id so the + * renderer resolves the furni's name/data by typeId. + */ +public final class FurnidataEntryBuilder { + + private FurnidataEntryBuilder() {} + + public static String build(Item item, String name, String description) { + String classname = item.getName() != null ? item.getName() : ""; + String safeName = (name != null && !name.isBlank()) ? name + : (item.getFullName() != null && !item.getFullName().isBlank()) ? item.getFullName() + : classname; + String safeDesc = description != null ? description : ""; + String customParams = item.getCustomParams() != null ? item.getCustomParams() : ""; + + StringBuilder b = new StringBuilder(256); + b.append("{\"id\":").append(item.getSpriteId()); + b.append(",\"classname\":\"").append(esc(classname)).append('"'); + b.append(",\"revision\":0,\"category\":\"unknown\",\"defaultdir\":0"); + b.append(",\"xdim\":").append(item.getWidth()); + b.append(",\"ydim\":").append(item.getLength()); + b.append(",\"partcolors\":{\"color\":[]}"); + b.append(",\"name\":\"").append(esc(safeName)).append('"'); + b.append(",\"description\":\"").append(esc(safeDesc)).append('"'); + b.append(",\"adurl\":\"\",\"offerid\":-1,\"buyout\":false,\"rentofferid\":-1,\"rentbuyout\":false,\"bc\":false,\"excludeddynamic\":false"); + b.append(",\"customparams\":\"").append(esc(customParams)).append('"'); + b.append(",\"specialtype\":1"); + b.append(",\"canstandon\":").append(item.allowWalk()); + b.append(",\"cansiton\":").append(item.allowSit()); + b.append(",\"canlayon\":").append(item.allowLay()); + b.append('}'); + return b.toString(); + } + + /** Escape for a JSON string value; collapse control chars to spaces. */ + private static String esc(String v) { + StringBuilder b = new StringBuilder(v.length() + 8); + for (int i = 0; i < v.length(); i++) { + char c = v.charAt(i); + if (c == '"' || c == '\\') b.append('\\').append(c); + else if (c == '\n' || c == '\r' || c == '\t') b.append(' '); + else b.append(c); + } + return b.toString(); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java index fd4f701a..d0778c3f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java @@ -56,6 +56,98 @@ public class FurnidataWriter { return true; } + /** Outcome of a {@link #create} attempt. */ + public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR } + + /** + * Append a brand-new furnidata entry (upsert's "create" half). Refuses if the + * classname already exists (caller should edit instead) or if {@code id} is + * already used by a DIFFERENT classname (id collision would break the + * {@code roomItem.name.} / typeId resolution on the renderer). The complete + * entry object is built by the caller (see FurnidataEntryBuilder) and inserted + * right after the opening '[' of the matching section's "furnitype" array. + * + * @param classname new classname (must be absent from furnidata) + * @param id furnidata id (= item sprite id); must not collide + * @param type FLOOR -> roomitemtypes, WALL -> wallitemtypes + * @param entryJson5 the complete entry object as a single-line JSON5 string + * @param createTier split-tier only: the tier dir to write into (e.g. "custom"); ignored for single-file + */ + public CreateResult create(String classname, int id, FurnitureType type, String entryJson5, String createTier) { + String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT); + if (cn.isEmpty() || entryJson5 == null || entryJson5.isBlank()) return CreateResult.NO_TARGET; + + // Guard: duplicate classname / id collision (scan the whole source). + for (FurnidataEntry e : new FurnidataReader(source, maxBytes).read()) { + String ecn = e.classname() == null ? "" : e.classname().trim().toLowerCase(java.util.Locale.ROOT); + if (ecn.equals(cn)) return CreateResult.ALREADY_EXISTS; + if (e.id() == id) return CreateResult.ID_COLLISION; + } + + try { + Path target = resolveCreateTarget(createTier); + if (target == null) return CreateResult.NO_TARGET; + + String raw = Files.readString(target, StandardCharsets.UTF_8); + String section = (type == FurnitureType.WALL) ? "wallitemtypes" : "roomitemtypes"; + int open = furnitypeArrayOpenIndex(raw, section); + if (open < 0) return CreateResult.NO_TARGET; // section/array absent in target file + + String edited = raw.substring(0, open) + "\n" + entryJson5 + "," + raw.substring(open); + backup(target); + atomicWrite(target, edited); + return CreateResult.CREATED; + } catch (IOException e) { + return CreateResult.IO_ERROR; + } + } + + /** Single-file: the source. Split-tier: the create-tier file (created with a shell if absent). */ + private Path resolveCreateTarget(String createTier) throws IOException { + if (!directory) return source; + String tier = (createTier == null || createTier.isBlank()) ? "custom" : createTier.trim(); + Path base = source.toAbsolutePath().normalize(); + Path tierDir = safeResolve(base, tier); + if (tierDir == null) return null; + if (!Files.isDirectory(tierDir)) Files.createDirectories(tierDir); + for (String fileName : manifestList(tierDir, "files", List.of())) { + Path f = safeResolve(base, tierDir.resolve(fileName).toString()); + if (f != null && Files.isRegularFile(f)) return f; + } + Path def = tierDir.resolve("furnidata.json5"); + if (!Files.exists(def)) { + Files.writeString(def, + "{\n \"roomitemtypes\": { \"furnitype\": [\n] },\n \"wallitemtypes\": { \"furnitype\": [\n] }\n}\n", + StandardCharsets.UTF_8); + } + return def; + } + + /** Index just after the '[' that opens {@code
.furnitype}, or -1 if absent. String-aware. */ + static int furnitypeArrayOpenIndex(String raw, String section) { + int s = indexOfKey(raw, section, 0); + if (s < 0) return -1; + int ft = indexOfKey(raw, "furnitype", s); + if (ft < 0) return -1; + boolean inStr = false; char q = 0; + for (int i = ft; i < raw.length(); i++) { + char c = raw.charAt(i); + if (inStr) { if (c == '\\') i++; else if (c == q) inStr = false; continue; } + if (c == '"' || c == '\'') { inStr = true; q = c; } + else if (c == '[') return i + 1; + } + return -1; + } + + /** First occurrence of a quoted key ("key" or 'key') at/after {@code from}, or -1. */ + private static int indexOfKey(String raw, String key, int from) { + int a = raw.indexOf("\"" + key + "\"", from); + int b = raw.indexOf("'" + key + "'", from); + if (a < 0) return b; + if (b < 0) return a; + return Math.min(a, b); + } + /** For single-file just returns the file; for split-tier, the tier file that contains cn. */ private Path locateFile(String cn) throws IOException { if (!directory) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java index fcdea56c..1d287261 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java @@ -5,6 +5,8 @@ import com.eu.habbo.habbohotel.items.FurnidataEntry; import com.eu.habbo.habbohotel.items.FurnidataLock; import com.eu.habbo.habbohotel.items.FurnidataWriter; import com.eu.habbo.habbohotel.items.FurnitureTextProvider; +import com.eu.habbo.habbohotel.items.Item; +import com.eu.habbo.habbohotel.items.FurnidataEntryBuilder; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.messages.incoming.MessageHandler; @@ -109,6 +111,7 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { String safeDesc = (description != null) ? description : ""; boolean written; + boolean created = false; List delta; FurnidataLock.LOCK.lock(); @@ -121,8 +124,37 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { ); written = writer.write(classname, safeName, safeDesc); if (!written) { - this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata")); - return; + // Upsert: no furnidata entry for this classname yet → create a + // complete one seeded from items_base (id = sprite id). + Item item = Emulator.getGameEnvironment().getItemManager().getItem(itemId); + if (item == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found")); + return; + } + String createTier = Emulator.getConfig().getValue("items.furnidata.create_tier", "custom"); + String entry = FurnidataEntryBuilder.build( + item, + FurnitureTextProvider.sanitize(safeName), + FurnitureTextProvider.sanitize(safeDesc)); + FurnidataWriter.CreateResult cr = + writer.create(item.getName(), item.getSpriteId(), item.getType(), entry, createTier); + switch (cr) { + case CREATED: + created = true; + written = true; + break; + case ALREADY_EXISTS: + // entry already present (race / no-op edit) — apply the edit and treat as success + writer.write(classname, safeName, safeDesc); + written = true; + break; + case ID_COLLISION: + this.client.sendResponse(new FurniEditorResultComposer(false, "Sprite id already used by another classname")); + return; + default: + this.client.sendResponse(new FurniEditorResultComposer(false, "Failed to create furnidata entry")); + return; + } } delta = provider.reindexFromSource(); @@ -161,7 +193,7 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { FurnidataAuditLog.record( adminId, classname, - "edit", + created ? "create" : "edit", oldName != null ? oldName : "", FurnitureTextProvider.sanitize(safeName), oldDesc, From 00812803284ddef01a1177ad62f13fc1c567f944 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 19:30:17 +0200 Subject: [PATCH 30/33] fix(catalog): claim vouchers before rewards Move voucher exhaustion checks and history persistence behind a synchronized per-voucher claim path. Rewards are now applied only after the history row is inserted successfully, preventing duplicate or failed-claim redemption from granting credits, points, or catalog items. Adds a contract test for claim ordering. Maven verification was attempted but blocked by sandbox network/plugin resolution after escalation usage was exhausted; diff --check passes. --- .../habbohotel/catalog/CatalogManager.java | 24 +++++---- .../eu/habbo/habbohotel/catalog/Voucher.java | 31 ++++++++++-- .../catalog/VoucherClaimContractTest.java | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java index 0349184f..29e6ff11 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java @@ -711,18 +711,22 @@ public class CatalogManager { return; } - if (voucher.isExhausted()) { - client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR)); - return; + Voucher.ClaimResult claimResult = voucher.claimForUser(habbo.getHabboInfo().getId()); + switch (claimResult) { + case CLAIMED: + break; + case EXHAUSTED: + client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR)); + return; + case USER_LIMIT: + client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher.")); + return; + case FAILED: + default: + client.sendResponse(new RedeemVoucherErrorComposer(RedeemVoucherErrorComposer.TECHNICAL_ERROR)); + return; } - if (voucher.hasUserExhausted(habbo.getHabboInfo().getId())) { - client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher.")); - return; - } - - voucher.addHistoryEntry(habbo.getHabboInfo().getId()); - if (voucher.points > 0) { client.getHabbo().givePoints(voucher.pointsType, voucher.points); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java index 468e2a2b..59cf0b5f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java @@ -14,6 +14,13 @@ import java.util.List; public class Voucher { private static final Logger LOGGER = LoggerFactory.getLogger(Voucher.class); + public enum ClaimResult { + CLAIMED, + EXHAUSTED, + USER_LIMIT, + FAILED + } + public final int id; public final String code; public final int credits; @@ -58,18 +65,34 @@ public class Voucher { return this.amount > 0 && this.history.size() >= this.amount; } - public void addHistoryEntry(int userId) { - int timestamp = Emulator.getIntUnixTimestamp(); - this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp)); + public synchronized ClaimResult claimForUser(int userId) { + if (this.isExhausted()) { + return ClaimResult.EXHAUSTED; + } + if (this.hasUserExhausted(userId)) { + return ClaimResult.USER_LIMIT; + } + + int timestamp = Emulator.getIntUnixTimestamp(); + if (!this.insertHistoryEntry(userId, timestamp)) { + return ClaimResult.FAILED; + } + + this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp)); + return ClaimResult.CLAIMED; + } + + private boolean insertHistoryEntry(int userId, int timestamp) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO voucher_history (`voucher_id`, `user_id`, `timestamp`) VALUES (?, ?, ?)")) { statement.setInt(1, this.id); statement.setInt(2, userId); statement.setInt(3, timestamp); - statement.execute(); + return statement.executeUpdate() > 0; } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); + return false; } } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java new file mode 100644 index 00000000..c3639ec6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java @@ -0,0 +1,50 @@ +package com.eu.habbo.habbohotel.catalog; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class VoucherClaimContractTest { + private static String voucherSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java")); + } + + private static String catalogManagerSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java")); + } + + @Test + void voucherClaimIsSynchronizedAndPersistsBeforeRewardEligibility() throws Exception { + String source = voucherSource(); + + assertTrue(source.contains("public synchronized ClaimResult claimForUser(int userId)"), + "voucher claim should check limits and persist history under a per-voucher lock"); + assertTrue(source.contains("private boolean insertHistoryEntry"), + "history insert should report database failure to the caller"); + + int insertCall = source.indexOf("insertHistoryEntry(userId, timestamp)"); + int memoryAppend = source.indexOf("this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp))"); + + assertTrue(insertCall > -1, "claimForUser must persist the history row"); + assertTrue(memoryAppend > insertCall, + "in-memory history must only be updated after the database insert succeeds"); + } + + @Test + void catalogRewardsOnlyAfterVoucherClaimSucceeds() throws Exception { + String source = catalogManagerSource(); + + int claim = source.indexOf("Voucher.ClaimResult claimResult = voucher.claimForUser"); + int claimedGuard = source.indexOf("case CLAIMED", claim); + int pointsGrant = source.indexOf("client.getHabbo().givePoints", claim); + int creditsGrant = source.indexOf("client.getHabbo().giveCredits", claim); + + assertTrue(claim > -1, "CatalogManager must claim the voucher before applying rewards"); + assertTrue(claimedGuard > claim, "voucher rewards should only continue for a CLAIMED result"); + assertTrue(pointsGrant > claimedGuard, "points must be granted only after CLAIMED"); + assertTrue(creditsGrant > claimedGuard, "credits must be granted only after CLAIMED"); + } +} From 8e21765676b1b9a52becb7ee795695b2cf530ea0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:37:58 +0200 Subject: [PATCH 31/33] fix(polls): scope answers to active room poll Require poll answer, cancel, and question-data packets to match the poll configured on the caller's current room. Previously a crafted packet could target any loaded poll id and submit the final question directly, including badge-reward polls, without being in a room where that poll was active. Keep word quiz handling null-safe and add a contract test covering current-room poll scoping for all poll handlers. --- .../incoming/polls/AnswerPollEvent.java | 11 ++++++- .../incoming/polls/CancelPollEvent.java | 5 +++ .../incoming/polls/GetPollDataEvent.java | 6 ++++ .../polls/PollRoomScopeContractTest.java | 33 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/polls/PollRoomScopeContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/AnswerPollEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/AnswerPollEvent.java index 180b0a29..a419f63d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/AnswerPollEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/AnswerPollEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.polls; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.polls.Poll; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.users.HabboBadge; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer; @@ -31,12 +32,20 @@ public class AnswerPollEvent extends MessageHandler { if(answer.length() <= 0) return; if (pollId == 0 && questionId <= 0) { - this.client.getHabbo().getHabboInfo().getCurrentRoom().handleWordQuiz(this.client.getHabbo(), answer.toString()); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room != null) { + room.handleWordQuiz(this.client.getHabbo(), answer.toString()); + } return; } answer = new StringBuilder(answer.substring(1)); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null || room.getPollId() != pollId) { + return; + } + Poll poll = Emulator.getGameEnvironment().getPollManager().getPoll(pollId); if (poll != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/CancelPollEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/CancelPollEvent.java index a38b5a03..3d261fe3 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/CancelPollEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/CancelPollEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.polls; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.polls.Poll; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.messages.incoming.MessageHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +18,10 @@ public class CancelPollEvent extends MessageHandler { public void handle() throws Exception { int pollId = this.packet.readInt(); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null || room.getPollId() != pollId) { + return; + } Poll poll = Emulator.getGameEnvironment().getPollManager().getPoll(pollId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/GetPollDataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/GetPollDataEvent.java index 491b20d1..e16bd0d8 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/GetPollDataEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/polls/GetPollDataEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.polls; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.polls.Poll; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.polls.PollQuestionsComposer; @@ -10,6 +11,11 @@ public class GetPollDataEvent extends MessageHandler { public void handle() throws Exception { int pollId = this.packet.readInt(); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null || room.getPollId() != pollId) { + return; + } + Poll poll = Emulator.getGameEnvironment().getPollManager().getPoll(pollId); if (poll != null) { diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/polls/PollRoomScopeContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/polls/PollRoomScopeContractTest.java new file mode 100644 index 00000000..32fa2668 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/polls/PollRoomScopeContractTest.java @@ -0,0 +1,33 @@ +package com.eu.habbo.messages.incoming.polls; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PollRoomScopeContractTest { + @Test + void pollHandlersRequireMatchingCurrentRoomPoll() throws Exception { + assertRequiresMatchingRoomPoll("AnswerPollEvent.java"); + assertRequiresMatchingRoomPoll("CancelPollEvent.java"); + assertRequiresMatchingRoomPoll("GetPollDataEvent.java"); + } + + private void assertRequiresMatchingRoomPoll(String fileName) throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/polls/" + fileName)); + int packetPollId = source.indexOf("int pollId = this.packet.readInt();"); + int pollLookup = source.indexOf("getPoll(pollId)"); + + assertTrue(packetPollId >= 0, fileName + " must read the poll id from the packet"); + assertTrue(pollLookup >= 0, fileName + " must look up the requested poll explicitly"); + + String guardedSection = source.substring(packetPollId, pollLookup); + + assertTrue(guardedSection.contains("getCurrentRoom()"), + fileName + " must bind poll actions to the caller's current room"); + assertTrue(guardedSection.contains("room == null || room.getPollId() != pollId"), + fileName + " must reject poll ids that are not active in the current room"); + } +} From df2a849adceb04cad9131d257c452b9dcd7ea849 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 15:59:56 +0200 Subject: [PATCH 32/33] fix(rooms): bound rights removal batches --- .../users/RoomUserRemoveRightsEvent.java | 9 ++++++ .../RoomUserRemoveRightsContractTest.java | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsEvent.java index cc03c734..987ec101 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsEvent.java @@ -3,8 +3,12 @@ package com.eu.habbo.messages.incoming.rooms.users; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.util.PacketGuard; public class RoomUserRemoveRightsEvent extends MessageHandler { + private static final int MAX_RIGHTS_REMOVALS = 100; + private static final int BYTES_PER_USER_ID = 4; + @Override public void handle() throws Exception { int amount = this.packet.readInt(); @@ -15,6 +19,11 @@ public class RoomUserRemoveRightsEvent extends MessageHandler { return; if (room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { + if (!PacketGuard.isCountInRange(amount, 1, MAX_RIGHTS_REMOVALS) + || !PacketGuard.hasFixedWidthEntries(this.packet, amount, BYTES_PER_USER_ID)) { + return; + } + for (int i = 0; i < amount; i++) { int userId = this.packet.readInt(); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsContractTest.java new file mode 100644 index 00000000..22b5c09c --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsContractTest.java @@ -0,0 +1,32 @@ +package com.eu.habbo.messages.incoming.rooms.users; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class RoomUserRemoveRightsContractTest { + private static final Path SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsEvent.java"); + + @Test + void removeRightsBatchIsBoundedAndRequiresCompletePayload() throws IOException { + String source = Files.readString(SOURCE); + + assertTrue(source.contains("private static final int MAX_RIGHTS_REMOVALS = 100;")); + assertTrue(source.contains("PacketGuard.isCountInRange(amount, 1, MAX_RIGHTS_REMOVALS)")); + assertTrue(source.contains("PacketGuard.hasFixedWidthEntries(this.packet, amount, BYTES_PER_USER_ID)")); + + int guardIndex = source.indexOf("PacketGuard.isCountInRange(amount, 1, MAX_RIGHTS_REMOVALS)"); + int payloadIndex = source.indexOf("PacketGuard.hasFixedWidthEntries(this.packet, amount, BYTES_PER_USER_ID)"); + int readIndex = source.indexOf("int userId = this.packet.readInt();"); + int removeIndex = source.indexOf("room.removeRights(userId);"); + + assertTrue(guardIndex < readIndex, "batch size should be validated before reading user ids"); + assertTrue(payloadIndex < readIndex, "payload length should be validated before reading user ids"); + assertTrue(readIndex < removeIndex, "rights should only be removed after reading a validated user id"); + } +} From 54ef2ee2510aa57fa307ea94f765959e17f7b959 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:30:37 +0200 Subject: [PATCH 33/33] =?UTF-8?q?docs(furni-editor):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20create=20furnidata=20entry=20if=20missing=20(upsert?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-13-furnidata-create-if-missing-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md diff --git a/docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md b/docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md new file mode 100644 index 00000000..c575fc10 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md @@ -0,0 +1,151 @@ +# Furni Editor — create furnidata entry if missing (upsert) + +**Date:** 2026-06-13 +**Status:** Approved design → implementation +**Repos:** Arcturus-Morningstar-Extended (emulator, primary), Nitro-V3 (client, minor) + +## Problem + +In the in-client **Furni Editor**, many furni have **no matching entry in the +furnidata** (split-tier `*.json5` files). Today the editor detects this +(`furniDataEntry === null`), shows a "Public Name (DB fallback)" and **locks** +the name/description fields with the warning "this furni has no matching +furnidata entry … so its display name can't be edited here." +`FurnidataWriter.write()` is **edit-only** — it refuses classnames absent from +furnidata. There is no path to **create** the missing entry. + +Goal: let an operator create the missing furnidata entry directly from the +editor, so the furni gets a real, editable name/description. + +## Decisions (from brainstorming) + +1. **Trigger = upsert Save.** When the entry is missing, the name/desc fields + are *unlocked* (name prefilled from the DB Public Name); the existing "Save + name/desc" creates the entry if absent, edits it if present. No separate + button beyond a relabel ("Create entry" when missing). +2. **Completeness = full entry seeded from `items_base`.** The created entry is + a complete furnidata object (structural fields read from the item's DB row), + not a name-only stub. +3. **Target = config key `furnidata.editor.create_tier` (default `custom`).** + Split-tier → that tier file; single-file furnidata → the single file. + +## Approach + +**Reuse the existing `FurniEditorUpdateFurnidata` packet** (outgoing header +`10046`, result `10044`) and make the **server handler upsert**. Rejected +alternative: a dedicated `Create` packet (10050) — unnecessary, because the +create needs **no extra client-supplied fields** (the server reads `items_base` +for the structural fields and takes name/desc from the existing 10046 payload). + +**Net wire impact: none.** No renderer changes, no new packet. Only: +- Emulator: a new `FurnidataWriter.create(...)` + the 10046 handler becomes + upsert + one config key + an `items_base → furnidata` field mapper. +- Client: unlock the name/desc fields when the entry is missing + relabel Save. + +## Emulator changes (Java) + +### 1. `habbohotel/items/FurnidataWriter.create(...)` +New method, mirrors `write()`'s safety (locate target file, **backup + +atomic write**, preserve JSON5 formatting/comments): +- Resolve target file: read config `furnidata.editor.create_tier` (default + `custom`). If split-tier (manifest present) → that tier's file (create the + file with a valid empty-array JSON5 shell if it doesn't exist yet). If + single-file furnidata → the single file. +- Append a complete entry object (see field mapping) to the correct array + (`roomitemtypes` for floor / `wallitemtypes` for wall). +- **Guards:** refuse if the classname already exists anywhere in furnidata + (caller routes to edit instead); refuse if the chosen `id` (sprite id) is + already used by a *different* classname (id collision would break + `roomItem.name.{id}` / `getFloorItemData(typeId)` resolution). +- Return a result enum/boolean (created / already-exists / id-collision / + io-error) so the handler can message the operator precisely. + +### 2. `FurniEditorUpdateFurnidataEvent` (header 10046) → upsert +- Resolve classname + the full `items_base` row from `itemId` (handler already + resolves classname). +- If furnidata **has** the classname → existing edit path (`write()`). +- Else → build the complete entry from `items_base` + submitted name/desc → + `FurnidataWriter.create(...)`. +- After either path (unchanged from edit): `FurnitureTextProvider.reindexFromSource()`, + broadcast `FurnitureDataReloadComposer` (10047), mirror name into + `items_base.public_name`, audit log (action `"create"` vs `"edit"`), respond + `FurniEditorResultComposer` (10044) with success/precise error. +- Permission `ACC_CATALOGFURNI` + 1000ms rate-limit (unchanged). + +### 3. Config key +`furnidata.editor.create_tier` (default `custom`), read where the writer +resolves the target file. + +### 4. `items_base → furnidata` field mapping (helper) +Read the item's DB definition and map to furnidata JSON. Minimum complete set +(exact column/field names verified during implementation against the +`FurnidataReader` schema + `items_base`): +- `id` = item **sprite id** (the visual/type id — MUST match so the furni + resolves its name/data), `classname` = `item_name`, +- `type` = `"s"` (floor) / `"i"` (wall) from the item type, +- `name` = submitted name (fallback: public_name → classname), `description` = + submitted description, +- `xdim`/`ydim` = width/length, `canstandon`/`cansiton`/`canlayon` from the + item's stand/sit/lay flags, plus the standard furnidata defaults for the + remaining fields (`partcolors`, `offerid = -1`, `buyout`, `bc`, + `excludeddynamic`, `customparams`, `specialtype`, `furniline`, + `environment`, `rare`, `revision`, `category`). + +## Client changes (React) — `FurniEditorEditView.tsx` + +- When `furniDataEntry === null`: **unlock** the name/description inputs + (currently gated by the `furnidataEditable` memo), prefill name from + `item.publicName`, description blank. Replace the "can't be edited here" + warning with an informational note: "No furnidata entry yet — saving will + create one in the «custom» tier." Relabel the Save button to "Create entry" + while missing. +- The Save handler is unchanged — it already sends + `FurniEditorUpdateFurnidataComposer(itemId, { name, description })`. +- On `FurniEditorResultEvent` success, re-fetch detail + (`FurniEditorDetailComposer(itemId)`) so `furniDataEntry` populates and the UI + flips to normal edit mode. + +## Data flow + +``` +Save (entry missing) + → 10046 UpdateFurnidata(itemId, {name, desc}) + → handler: classname absent → build complete entry from items_base + name/desc + → FurnidataWriter.create(...) into the custom tier (atomic + backup) + → reindexFromSource() + broadcast 10047 FurnitureDataReload + → every client's catalog/inventory/infostand refreshes; the rendered + furni now resolves its real name + → mirror items_base.public_name + → audit "create" + → 10044 result(success) + → client re-fetches detail → entry now present → normal edit mode +``` + +## Error handling / edge cases + +- Classname already present (lookup race) → routed to edit (upsert). +- Sprite id already used by a different classname → refuse + "id N already + used by classname X". +- `items_base` row missing → refuse + error (shouldn't happen for a known item). +- Tier file absent → created with a valid JSON5 shell. +- Empty submitted name → fall back to public_name, else classname. +- Concurrency: reuse `write()`'s file lock + atomic write + backup. + +## Testing + +- **Emulator unit:** `FurnidataWriter.create` writes a valid JSON5 entry into + the target tier; idempotency guard (already-exists); id-collision guard; + round-trips through `FurnidataReader`. +- **Runtime (Chrome handle available):** in the Furni Editor select a furni + with no furnidata entry (the live "DB fallback" case), type a name, Save → + entry created, furni name updates live (10047 broadcast), reopen → entry + present and editable. Verify the new object lands in the `custom` tier file + and `FurnidataReader` parses it. + +## Out of scope + +- No new wire packet; no renderer changes. +- No bulk/batch creation; one furni at a time via the editor. +- No editing of structural fields from the UI (only name/desc, as today); the + structural fields are seeded once at creation from `items_base`. +- No deletion of furnidata entries (separate concern).