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/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 3c3d93b2..720b6af0 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -18,6 +18,8 @@ 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.fusesource.jansi.AnsiConsole; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +40,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. @@ -65,7 +73,6 @@ public final class Emulator { "██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝███████║ ██║ ██║ ██║██║ ██║\n" + "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝\n" + "Still Rocking in 2026.\n"; - public static String build = ""; public static long buildTimestamp = -1L; @@ -104,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(); @@ -119,7 +124,7 @@ public final class Emulator { ConsoleCommand.load(); Emulator.logging = new Logging(); - System.out.println(logo); + System.out.println(startupHero(styledConsole)); long startTime = System.nanoTime(); @@ -153,23 +158,10 @@ 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("rcon.rate_limit.enabled", "1"); - Emulator.config.register("rcon.rate_limit.limit_for_period", "60"); - Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000"); - Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); - Emulator.config.register("rcon.mute.max_duration_seconds", "604800"); - Emulator.config.register("rcon.achievement.max_progress", "10000"); - Emulator.config.register("rcon.execute_command.max_length", "256"); - Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_update_config;cmd_update_permissions;cmd_give_rank;cmd_badge;cmd_gift;cmd_credits;cmd_points;cmd_pixels;cmd_massbadge;cmd_masscredits;cmd_massgift;cmd_massduckets;cmd_masspoints;cmd_empty;cmd_empty_bots;cmd_empty_pets;cmd_unload;cmd_ban;cmd_superban;cmd_ip_ban;cmd_machine_ban;cmd_disconnect"); - Emulator.config.register("rcon.execute_command.allowed_permissions", ""); + 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(); - 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*"); @@ -207,7 +199,7 @@ public final class Emulator { Emulator.isReady = true; Emulator.timeStarted = getIntUnixTimestamp(); - if (Emulator.getConfig().getBoolean("gui.enabled", true)) { + if (shouldLaunchGui()) { EmulatorDashboard.launch(); } @@ -319,6 +311,97 @@ 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"; + } + + 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" + + " | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" + + " | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" + + " |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" + + "\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"; + } + + static boolean shouldStyleConsole(Map environment, boolean interactiveConsole, String osName, String styleProperty) { + 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); + } + } + + 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) { + 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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..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 @@ -90,7 +90,13 @@ 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())); + 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(), @@ -101,6 +107,11 @@ 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)", total); + } + public boolean canUse(Habbo habbo, Category category, int id) { if (id == 0) return true; if (!this.enforce) return true; 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/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/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/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/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/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/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, 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/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/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/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/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/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/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/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/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/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/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/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/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 f4a62d3f..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} [%-14thread] %-5level %-36logger{36} - %msg%n + %d{HH:mm:ss.SSS} %morningstarLevel [%-12thread] %morningstarLogger | %msg%n @@ -65,4 +68,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..4e0d3724 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/ConsoleLogbackLayoutTest.java @@ -0,0 +1,21 @@ +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("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/EmulatorStartupConsoleTest.java b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java new file mode 100644 index 00000000..c748f081 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/EmulatorStartupConsoleTest.java @@ -0,0 +1,88 @@ +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; + +class EmulatorStartupConsoleTest { + @Test + void startupHeroUsesUniversalAsciiLayout() { + String hero = Emulator.startupHero(); + + assertTrue(hero.contains("__ __ ___ ____")); + assertTrue(hero.contains("MORNINGSTAR EXTENDED")); + assertTrue(hero.contains("Version")); + 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")); + } + + @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"); + } + + @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\", \"0\")"), + "gui.enabled must be registered disabled by default so it does not log missing config errors or start the UI unexpectedly"); + 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"); + } +} 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"); + } + } +} 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"); + } +} 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"); + } +} 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"); + } +} 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"); + } +} 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); + } +} 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"); + } +} 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"); + } +} 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"); + } +} 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"); + } +} 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..531ed49e --- /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)", + InfostandBackgroundManager.summary(188, 22, 9, 16, 25)); + } +} 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); + } +} 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)); + } +} 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")); + } +} 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()); + } } 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,")); + } +} 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"); + } +} 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"); + } +} 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"); + } +} 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"); + } + } +} 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"); + } +} 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")); + } +} 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`. 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).