From 458b37dbed0901696420a6741d4c748684ab0e08 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 02:39:01 +0200 Subject: [PATCH 1/5] feat: rare values + fortune wheel + in-client prize editor Catalog-derived rare value map (diamond-priced), fortune wheel (WheelManager, weighted RNG, lazy daily reset, rewards, recent wins) + admin prize editor gated on acc_supporttool. Packets 9300-9305 / 9400-9404. Migration 020. --- .../020_fortune_wheel.sql | 58 ++++ .../eu/habbo/habbohotel/GameEnvironment.java | 7 + .../habbohotel/catalog/CatalogManager.java | 53 +++ .../habbo/habbohotel/wheel/WheelManager.java | 327 ++++++++++++++++++ .../eu/habbo/habbohotel/wheel/WheelPrize.java | 44 +++ .../habbohotel/wheel/WheelRecentWin.java | 14 + .../habbohotel/wheel/WheelUserState.java | 12 + .../com/eu/habbo/messages/PacketManager.java | 8 + .../eu/habbo/messages/incoming/Incoming.java | 8 + .../rarevalues/RequestRareValuesEvent.java | 21 ++ .../wheel/WheelAdminGetPrizesEvent.java | 23 ++ .../wheel/WheelAdminSavePrizesEvent.java | 45 +++ .../incoming/wheel/WheelBuySpinEvent.java | 27 ++ .../incoming/wheel/WheelOpenEvent.java | 27 ++ .../incoming/wheel/WheelSpinEvent.java | 39 +++ .../eu/habbo/messages/outgoing/Outgoing.java | 7 + .../rarevalues/RareValuesComposer.java | 35 ++ .../wheel/WheelAdminPrizesComposer.java | 34 ++ .../outgoing/wheel/WheelDataComposer.java | 46 +++ .../wheel/WheelRecentWinsComposer.java | 29 ++ .../outgoing/wheel/WheelResultComposer.java | 22 ++ 21 files changed, 886 insertions(+) create mode 100644 Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelPrize.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelRecentWin.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelUserState.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/rarevalues/RequestRareValuesEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelBuySpinEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelOpenEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelSpinEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/rarevalues/RareValuesComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelAdminPrizesComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelDataComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelRecentWinsComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelResultComposer.java diff --git a/Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql b/Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql new file mode 100644 index 00000000..e158425c --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql @@ -0,0 +1,58 @@ +-- Fortune Wheel +-- Tables are also created at boot by WheelManager (CREATE TABLE IF NOT EXISTS), +-- so applying this file is only needed to seed prizes + settings. + +CREATE TABLE IF NOT EXISTS `wheel_prizes` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `type` VARCHAR(16) NOT NULL DEFAULT 'nothing', -- item | badge | credits | points | spin | nothing + `value` VARCHAR(64) NOT NULL DEFAULT '', -- item: base item id ; badge: badge code ; others: unused + `amount` INT(11) NOT NULL DEFAULT 1, -- item qty / credits / points / extra spins + `points_type` INT(11) NOT NULL DEFAULT 5, -- for type=points (diamond default 5) + `weight` INT(11) NOT NULL DEFAULT 1, -- relative probability + `label` VARCHAR(64) NOT NULL DEFAULT '', -- slice label override (optional) + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `wheel_user_state` ( + `user_id` INT(11) NOT NULL, + `free_spins` INT(11) NOT NULL DEFAULT 0, -- remaining free spins for the current day + `extra_spins` INT(11) NOT NULL DEFAULT 0, -- bought / won spins + `last_reset` INT(11) NOT NULL DEFAULT 0, -- day index of last daily reset (unix / 86400) + PRIMARY KEY (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `wheel_recent_wins` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) NOT NULL, + `username` VARCHAR(64) NOT NULL DEFAULT '', + `look` VARCHAR(255) NOT NULL DEFAULT '', + `prize_label` VARCHAR(64) NOT NULL DEFAULT '', + `won_at` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_wheel_recent_wins_id` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES + ('wheel.free_spins_per_day', '1', 'Fortune wheel: free spins granted each day.') + ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); +INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES + ('wheel.spin_cost', '50', 'Fortune wheel: cost of one extra spin.') + ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); +INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES + ('wheel.spin_cost_type', '5', 'Fortune wheel: currency type for the spin cost (5 = diamonds; -1 = credits).') + ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); + +-- Example prizes (currency / spin / nothing don't reference furniture ids). +-- Add `item`/`badge` rows with your own ids: e.g. +-- INSERT INTO wheel_prizes (type, value, amount, weight, label, sort_order) VALUES ('item','',1,5,'Raro',1); +-- INSERT INTO wheel_prizes (type, value, amount, weight, label, sort_order) VALUES ('badge','',1,5,'Distintivo',2); +INSERT INTO `wheel_prizes` (`type`, `amount`, `points_type`, `weight`, `label`, `sort_order`) VALUES + ('points', 25, 5, 20, '25 diamanti', 10), + ('points', 50, 5, 12, '50 diamanti', 11), + ('points', 200, 5, 3, '200 diamanti', 12), + ('credits', 100, 0, 15, '100 crediti', 13), + ('spin', 1, 0, 15, '1 Giro Extra', 14), + ('spin', 2, 0, 6, '2 Giri Extra', 15), + ('nothing', 0, 0, 29, 'Nulla', 16); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java index d8b8fe96..0a09636f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java @@ -6,6 +6,7 @@ import com.eu.habbo.habbohotel.achievements.AchievementManager; import com.eu.habbo.habbohotel.bots.BotManager; import com.eu.habbo.habbohotel.campaign.calendar.CalendarManager; import com.eu.habbo.habbohotel.catalog.CatalogManager; +import com.eu.habbo.habbohotel.wheel.WheelManager; import com.eu.habbo.habbohotel.commands.CommandHandler; import com.eu.habbo.habbohotel.crafting.CraftingManager; import com.eu.habbo.habbohotel.guides.GuideManager; @@ -64,6 +65,7 @@ public class GameEnvironment { private GoogleTranslateManager googleTranslateManager; private CustomBadgeManager customBadgeManager; private InfostandBackgroundManager infostandBackgroundManager; + private WheelManager wheelManager; public void load() throws Exception { LOGGER.info("GameEnvironment -> Loading..."); @@ -93,6 +95,7 @@ public class GameEnvironment { this.googleTranslateManager = new GoogleTranslateManager(); this.customBadgeManager = new CustomBadgeManager(); this.infostandBackgroundManager = new InfostandBackgroundManager(); + this.wheelManager = new WheelManager(); this.roomManager.loadPublicRooms(); this.navigatorManager.loadNavigator(); @@ -156,6 +159,10 @@ public class GameEnvironment { return this.catalogManager; } + public WheelManager getWheelManager() { + return this.wheelManager; + } + public HotelViewManager getHotelViewManager() { return this.hotelViewManager; } 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 8ac07faf..76452042 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 @@ -202,6 +202,8 @@ public class CatalogManager { public final Item ecotronItem; public final THashMap limitedNumbers; private final List vouchers; + // spriteId -> [credits, points, pointsType], derived from catalog_items (see loadFurnitureValues) + public final TIntObjectMap furnitureValues; public CatalogManager() { long millis = System.currentTimeMillis(); @@ -219,6 +221,7 @@ public class CatalogManager { this.buildersClubOfferDefs = new TIntIntHashMap(); this.vouchers = new ArrayList<>(); this.limitedNumbers = new THashMap<>(); + this.furnitureValues = new TIntObjectHashMap<>(); this.initialize(); @@ -243,6 +246,56 @@ public class CatalogManager { this.loadClothing(); this.loadRecycler(); this.loadGiftWrappers(); + this.loadFurnitureValues(); + } + + // Builds spriteId -> [credits, points, pointsType] from catalog_items so the + // client can show a furni's "value" (toolbar price guide + infostand line). + // Only single-item, single-amount FLOOR/WALL sales are considered, so bundles + // and multi-packs don't pollute the per-rare price. First clean entry wins. + private synchronized void loadFurnitureValues() { + this.furnitureValues.clear(); + final int diamondType = Emulator.getConfig().getInt("seasonal.currency.diamond", 5); + + for (CatalogPage page : this.catalogPages.valueCollection()) { + for (CatalogItem catalogItem : page.getCatalogItems().valueCollection()) { + if (catalogItem.getAmount() != 1) + continue; + + int credits = catalogItem.getCredits(); + int points = catalogItem.getPoints(); + int pointsType = catalogItem.getPointsType(); + + // Only diamond-priced items — both the "Valore Rari" panel and the + // infostand value line show diamonds only. + if (points <= 0 || pointsType != diamondType) + continue; + + THashSet baseItems = catalogItem.getBaseItems(); + + if (baseItems.size() != 1) + continue; + + for (Item item : baseItems) { + FurnitureType type = item.getType(); + + if (type != FurnitureType.FLOOR && type != FurnitureType.WALL) + continue; + + int spriteId = item.getSpriteId(); + + if (spriteId > 0 && !this.furnitureValues.containsKey(spriteId)) { + this.furnitureValues.put(spriteId, new int[]{credits, points, pointsType}); + } + } + } + } + + LOGGER.info("Furniture Values -> Loaded! ({} entries)", this.furnitureValues.size()); + } + + public TIntObjectMap getFurnitureValues() { + return this.furnitureValues; } private synchronized void loadLimitedNumbers() { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java new file mode 100644 index 00000000..e5ccc316 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java @@ -0,0 +1,327 @@ +package com.eu.habbo.habbohotel.wheel; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.items.Item; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboItem; +import gnu.trove.set.hash.THashSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class WheelManager { + private static final Logger LOGGER = LoggerFactory.getLogger(WheelManager.class); + private static final int RECENT_KEEP = 50; + private static final int SECONDS_PER_DAY = 86400; + + private final List prizes = new ArrayList<>(); + private int totalWeight = 0; + private int freeSpinsPerDay = 1; + private int spinCost = 50; + private int spinCostType = 5; + + public WheelManager() { + long millis = System.currentTimeMillis(); + this.createTables(); + this.reload(); + LOGGER.info("Wheel Manager -> Loaded! ({} MS)", System.currentTimeMillis() - millis); + } + + public void reload() { + this.loadSettings(); + this.loadPrizes(); + } + + private void createTables() { + final String[] ddl = { + "CREATE TABLE IF NOT EXISTS `wheel_prizes` (" + + "`id` INT(11) NOT NULL AUTO_INCREMENT, `type` VARCHAR(16) NOT NULL DEFAULT 'nothing', " + + "`value` VARCHAR(64) NOT NULL DEFAULT '', `amount` INT(11) NOT NULL DEFAULT 1, " + + "`points_type` INT(11) NOT NULL DEFAULT 5, `weight` INT(11) NOT NULL DEFAULT 1, " + + "`label` VARCHAR(64) NOT NULL DEFAULT '', `enabled` TINYINT(1) NOT NULL DEFAULT 1, " + + "`sort_order` INT(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `wheel_user_state` (" + + "`user_id` INT(11) NOT NULL, `free_spins` INT(11) NOT NULL DEFAULT 0, " + + "`extra_spins` INT(11) NOT NULL DEFAULT 0, `last_reset` INT(11) NOT NULL DEFAULT 0, " + + "PRIMARY KEY (`user_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `wheel_recent_wins` (" + + "`id` INT(11) NOT NULL AUTO_INCREMENT, `user_id` INT(11) NOT NULL, " + + "`username` VARCHAR(64) NOT NULL DEFAULT '', `look` VARCHAR(255) NOT NULL DEFAULT '', " + + "`prize_label` VARCHAR(64) NOT NULL DEFAULT '', `won_at` INT(11) NOT NULL DEFAULT 0, " + + "PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + }; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + Statement statement = connection.createStatement()) { + for (String query : ddl) { + statement.execute(query); + } + } catch (SQLException e) { + LOGGER.error("Failed to create fortune wheel tables", e); + } + } + + private void loadSettings() { + this.freeSpinsPerDay = Emulator.getConfig().getInt("wheel.free_spins_per_day", 1); + this.spinCost = Emulator.getConfig().getInt("wheel.spin_cost", 50); + this.spinCostType = Emulator.getConfig().getInt("wheel.spin_cost_type", 5); + } + + private void loadPrizes() { + this.prizes.clear(); + this.totalWeight = 0; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT * FROM wheel_prizes WHERE enabled = 1 ORDER BY sort_order ASC, id ASC"); + ResultSet set = statement.executeQuery()) { + while (set.next()) { + WheelPrize prize = new WheelPrize(set); + this.prizes.add(prize); + this.totalWeight += prize.weight; + } + } catch (SQLException e) { + LOGGER.error("Failed to load fortune wheel prizes", e); + } + } + + public List getPrizes() { + return this.prizes; + } + + public int getSpinCost() { + return this.spinCost; + } + + public int getSpinCostType() { + return this.spinCostType; + } + + private int today() { + return Emulator.getIntUnixTimestamp() / SECONDS_PER_DAY; + } + + // Reads the user's spin balance, applying the lazy daily reset and creating the row if missing. + public WheelUserState getUserState(int userId) { + WheelUserState state = new WheelUserState(); + boolean exists = false; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT free_spins, extra_spins, last_reset FROM wheel_user_state WHERE user_id = ?")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) { + state.freeSpins = set.getInt("free_spins"); + state.extraSpins = set.getInt("extra_spins"); + state.lastReset = set.getInt("last_reset"); + exists = true; + } + } + } catch (SQLException e) { + LOGGER.error("Failed to read wheel state for user {}", userId, e); + } + + int today = this.today(); + if (!exists) { + state.freeSpins = this.freeSpinsPerDay; + state.extraSpins = 0; + state.lastReset = today; + this.persistUserState(userId, state); + } else if (state.lastReset != today) { + state.freeSpins = this.freeSpinsPerDay; + state.lastReset = today; + this.persistUserState(userId, state); + } + + return state; + } + + private void persistUserState(int userId, WheelUserState state) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO wheel_user_state (user_id, free_spins, extra_spins, last_reset) VALUES (?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE free_spins = VALUES(free_spins), extra_spins = VALUES(extra_spins), last_reset = VALUES(last_reset)")) { + statement.setInt(1, userId); + statement.setInt(2, state.freeSpins); + statement.setInt(3, state.extraSpins); + statement.setInt(4, state.lastReset); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Failed to persist wheel state for user {}", userId, e); + } + } + + // Consumes a spin (free first, then extra), picks a weighted prize, grants it and records the win. + // Returns the prize, or null if the user has no spins or no prizes are configured. + public synchronized WheelPrize spin(Habbo habbo) { + int userId = habbo.getHabboInfo().getId(); + WheelUserState state = this.getUserState(userId); + + boolean usedFree; + if (state.freeSpins > 0) { + state.freeSpins--; + usedFree = true; + } else if (state.extraSpins > 0) { + state.extraSpins--; + usedFree = false; + } else { + return null; + } + + WheelPrize prize = this.pickWeighted(); + if (prize == null) { + // No prizes configured — refund the spin we just consumed. + if (usedFree) state.freeSpins++; else state.extraSpins++; + return null; + } + + this.giveReward(habbo, prize, state); + this.persistUserState(userId, state); + + // Record every spin (including "nothing") so the live feed shows all activity. + this.recordWin(habbo, prize); + + return prize; + } + + private WheelPrize pickWeighted() { + if (this.prizes.isEmpty() || this.totalWeight <= 0) return null; + + int roll = ThreadLocalRandom.current().nextInt(this.totalWeight); + int acc = 0; + for (WheelPrize prize : this.prizes) { + acc += prize.weight; + if (roll < acc) return prize; + } + return this.prizes.get(this.prizes.size() - 1); + } + + private void giveReward(Habbo habbo, WheelPrize prize, WheelUserState state) { + switch (prize.type) { + case "credits": + habbo.giveCredits(prize.amount); + break; + case "points": + habbo.givePoints(prize.pointsType, prize.amount); + break; + case "spin": + state.extraSpins += Math.max(0, prize.amount); + break; + case "item": + this.giveItem(habbo, prize); + break; + case "badge": + habbo.addBadge(prize.value, "Fortune Wheel"); + break; + case "nothing": + default: + break; + } + } + + private void giveItem(Habbo habbo, WheelPrize prize) { + int baseId; + try { + baseId = Integer.parseInt(prize.value.trim()); + } catch (NumberFormatException e) { + return; + } + + Item base = Emulator.getGameEnvironment().getItemManager().getItem(baseId); + if (base == null) return; + + int quantity = Math.max(1, prize.amount); + THashSet items = new THashSet<>(); + for (int i = 0; i < quantity; i++) { + HabboItem item = Emulator.getGameEnvironment().getItemManager().createItem(habbo.getHabboInfo().getId(), base, 0, 0, ""); + if (item != null) items.add(item); + } + + if (!items.isEmpty()) { + habbo.addFurniture(items); + } + } + + private void recordWin(Habbo habbo, WheelPrize prize) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO wheel_recent_wins (user_id, username, look, prize_label, won_at) VALUES (?, ?, ?, ?, ?)")) { + statement.setInt(1, habbo.getHabboInfo().getId()); + statement.setString(2, habbo.getHabboInfo().getUsername()); + statement.setString(3, habbo.getHabboInfo().getLook()); + statement.setString(4, prize.label); + statement.setInt(5, Emulator.getIntUnixTimestamp()); + statement.executeUpdate(); + } + + // Trim to the most recent RECENT_KEEP rows. + try (PreparedStatement trim = connection.prepareStatement( + "DELETE FROM wheel_recent_wins WHERE id < (SELECT id FROM (SELECT id FROM wheel_recent_wins ORDER BY id DESC LIMIT 1 OFFSET ?) t)")) { + trim.setInt(1, RECENT_KEEP - 1); + trim.executeUpdate(); + } + } catch (SQLException e) { + LOGGER.error("Failed to record wheel win", e); + } + } + + public List getRecentWins(int limit) { + List wins = new ArrayList<>(); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT username, look, prize_label FROM wheel_recent_wins ORDER BY id DESC LIMIT ?")) { + statement.setInt(1, limit); + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + wins.add(new WheelRecentWin(set.getString("username"), set.getString("look"), set.getString("prize_label"))); + } + } + } catch (SQLException e) { + LOGGER.error("Failed to load wheel recent wins", e); + } + return wins; + } + + // Buys one extra spin with the configured currency. Returns false if the user can't afford it. + public synchronized boolean buySpin(Habbo habbo) { + if (this.spinCost <= 0) return false; + + if (this.spinCostType == -1) { + if (habbo.getHabboInfo().getCredits() < this.spinCost) return false; + habbo.giveCredits(-this.spinCost); + } else { + if (habbo.getHabboInfo().getCurrencyAmount(this.spinCostType) < this.spinCost) return false; + habbo.givePoints(this.spinCostType, -this.spinCost); + } + + int userId = habbo.getHabboInfo().getId(); + WheelUserState state = this.getUserState(userId); + state.extraSpins++; + this.persistUserState(userId, state); + return true; + } + + // Admin: update one prize row. Caller reloads once after a batch. + public void savePrize(int id, String type, String value, int amount, int pointsType, int weight, String label) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE wheel_prizes SET type = ?, value = ?, amount = ?, points_type = ?, weight = ?, label = ? WHERE id = ?")) { + statement.setString(1, type != null ? type : "nothing"); + statement.setString(2, value != null ? value : ""); + statement.setInt(3, amount); + statement.setInt(4, pointsType); + statement.setInt(5, Math.max(0, weight)); + statement.setString(6, label != null ? label : ""); + statement.setInt(7, id); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Failed to save wheel prize {}", id, e); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelPrize.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelPrize.java new file mode 100644 index 00000000..7ed54cb6 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelPrize.java @@ -0,0 +1,44 @@ +package com.eu.habbo.habbohotel.wheel; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.items.Item; + +import java.sql.ResultSet; +import java.sql.SQLException; + +// One slice of the wheel. type = item | badge | credits | points | spin | nothing. +public class WheelPrize { + public final int id; + public final String type; + public final String value; // item: base item id ; badge: badge code ; others: unused + public final int amount; // item qty / credits / points / extra spins + public final int pointsType; // for type=points + public final int weight; + public final String label; + public final int spriteId; // resolved for item prizes so the client can render the furni icon + + public WheelPrize(ResultSet set) throws SQLException { + this.id = set.getInt("id"); + this.type = set.getString("type"); + this.value = set.getString("value"); + this.amount = set.getInt("amount"); + this.pointsType = set.getInt("points_type"); + this.weight = Math.max(0, set.getInt("weight")); + this.label = set.getString("label"); + this.spriteId = resolveSpriteId(this.type, this.value); + } + + private static int resolveSpriteId(String type, String value) { + if (!"item".equals(type) || value == null) return 0; + try { + Item item = Emulator.getGameEnvironment().getItemManager().getItem(Integer.parseInt(value.trim())); + return item != null ? item.getSpriteId() : 0; + } catch (NumberFormatException e) { + return 0; + } + } + + public String badgeCode() { + return "badge".equals(this.type) && this.value != null ? this.value : ""; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelRecentWin.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelRecentWin.java new file mode 100644 index 00000000..03d43816 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelRecentWin.java @@ -0,0 +1,14 @@ +package com.eu.habbo.habbohotel.wheel; + +// A row in the "latest winners" panel. Denormalized (username/look stored at win time). +public class WheelRecentWin { + public final String username; + public final String look; + public final String prizeLabel; + + public WheelRecentWin(String username, String look, String prizeLabel) { + this.username = username != null ? username : ""; + this.look = look != null ? look : ""; + this.prizeLabel = prizeLabel != null ? prizeLabel : ""; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelUserState.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelUserState.java new file mode 100644 index 00000000..f6522a52 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelUserState.java @@ -0,0 +1,12 @@ +package com.eu.habbo.habbohotel.wheel; + +// Per-user spin balance. freeSpins resets daily (lazy, on access); extraSpins persist. +public class WheelUserState { + public int freeSpins; + public int extraSpins; + public int lastReset; // day index (unix / 86400) of the last daily reset + + public int totalSpins() { + return this.freeSpins + this.extraSpins; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index a5e9b84a..7fbfec63 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -745,5 +745,13 @@ public class PacketManager { this.registerHandler(Incoming.HousekeepingSendHotelAlertEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingSendHotelAlertEvent.class); this.registerHandler(Incoming.HousekeepingGetDashboardEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingGetDashboardEvent.class); this.registerHandler(Incoming.HousekeepingListActionLogEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingListActionLogEvent.class); + + this.registerHandler(Incoming.RequestRareValuesEvent, com.eu.habbo.messages.incoming.rarevalues.RequestRareValuesEvent.class); + + this.registerHandler(Incoming.WheelOpenEvent, com.eu.habbo.messages.incoming.wheel.WheelOpenEvent.class); + this.registerHandler(Incoming.WheelSpinEvent, com.eu.habbo.messages.incoming.wheel.WheelSpinEvent.class); + this.registerHandler(Incoming.WheelBuySpinEvent, com.eu.habbo.messages.incoming.wheel.WheelBuySpinEvent.class); + this.registerHandler(Incoming.WheelAdminGetPrizesEvent, com.eu.habbo.messages.incoming.wheel.WheelAdminGetPrizesEvent.class); + this.registerHandler(Incoming.WheelAdminSavePrizesEvent, com.eu.habbo.messages.incoming.wheel.WheelAdminSavePrizesEvent.class); } } 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 4222bdfa..52b32bb2 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 @@ -486,4 +486,12 @@ public class Incoming { public static final int HousekeepingSendHotelAlertEvent = 9121; public static final int HousekeepingGetDashboardEvent = 9122; public static final int HousekeepingListActionLogEvent = 9123; + + // Custom features — IDs 9300+ reserved + public static final int RequestRareValuesEvent = 9300; + public static final int WheelOpenEvent = 9301; + public static final int WheelSpinEvent = 9302; + public static final int WheelBuySpinEvent = 9303; + public static final int WheelAdminGetPrizesEvent = 9304; + public static final int WheelAdminSavePrizesEvent = 9305; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rarevalues/RequestRareValuesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rarevalues/RequestRareValuesEvent.java new file mode 100644 index 00000000..117043a4 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rarevalues/RequestRareValuesEvent.java @@ -0,0 +1,21 @@ +package com.eu.habbo.messages.incoming.rarevalues; + +import com.eu.habbo.Emulator; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.rarevalues.RareValuesComposer; + +// Client requests the furni value map once on load. Public info (catalog prices), +// no permission gate. Rate limited since the payload is large. +public class RequestRareValuesEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 5000; + } + + @Override + public void handle() throws Exception { + this.client.sendResponse(new RareValuesComposer( + Emulator.getGameEnvironment().getCatalogManager().getFurnitureValues() + )); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java new file mode 100644 index 00000000..a8b4e23a --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java @@ -0,0 +1,23 @@ +package com.eu.habbo.messages.incoming.wheel; + +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.wheel.WheelAdminPrizesComposer; + +public class WheelAdminGetPrizesEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 500; + } + + @Override + public void handle() throws Exception { + if (this.client.getHabbo() == null || !this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { + return; + } + + this.client.sendResponse(new WheelAdminPrizesComposer( + Emulator.getGameEnvironment().getWheelManager().getPrizes())); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java new file mode 100644 index 00000000..d63ab6cb --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java @@ -0,0 +1,45 @@ +package com.eu.habbo.messages.incoming.wheel; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.wheel.WheelManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.wheel.WheelAdminPrizesComposer; +import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer; + +public class WheelAdminSavePrizesEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1000; + } + + @Override + public void handle() throws Exception { + if (this.client.getHabbo() == null || !this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { + return; + } + + WheelManager wheel = Emulator.getGameEnvironment().getWheelManager(); + + int count = this.packet.readInt(); + for (int i = 0; i < count; i++) { + int id = this.packet.readInt(); + String type = this.packet.readString(); + String value = this.packet.readString(); + int amount = this.packet.readInt(); + int pointsType = this.packet.readInt(); + int weight = this.packet.readInt(); + String label = this.packet.readString(); + + wheel.savePrize(id, type, value, amount, pointsType, weight, label); + } + + wheel.reload(); + + // Send the refreshed admin list + the player view so the editor updates live. + this.client.sendResponse(new WheelAdminPrizesComposer(wheel.getPrizes())); + this.client.sendResponse(new WheelDataComposer( + wheel.getUserState(this.client.getHabbo().getHabboInfo().getId()), + wheel.getSpinCost(), wheel.getSpinCostType(), wheel.getPrizes())); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelBuySpinEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelBuySpinEvent.java new file mode 100644 index 00000000..c0e585e6 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelBuySpinEvent.java @@ -0,0 +1,27 @@ +package com.eu.habbo.messages.incoming.wheel; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.wheel.WheelManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer; + +public class WheelBuySpinEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1000; + } + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + if (habbo == null) return; + + WheelManager wheel = Emulator.getGameEnvironment().getWheelManager(); + wheel.buySpin(habbo); // whether or not it succeeds, resend the balance + + this.client.sendResponse(new WheelDataComposer( + wheel.getUserState(habbo.getHabboInfo().getId()), + wheel.getSpinCost(), wheel.getSpinCostType(), wheel.getPrizes())); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelOpenEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelOpenEvent.java new file mode 100644 index 00000000..876ab96f --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelOpenEvent.java @@ -0,0 +1,27 @@ +package com.eu.habbo.messages.incoming.wheel; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.wheel.WheelManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer; +import com.eu.habbo.messages.outgoing.wheel.WheelRecentWinsComposer; + +public class WheelOpenEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 500; + } + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + if (habbo == null) return; + + WheelManager wheel = Emulator.getGameEnvironment().getWheelManager(); + this.client.sendResponse(new WheelDataComposer( + wheel.getUserState(habbo.getHabboInfo().getId()), + wheel.getSpinCost(), wheel.getSpinCostType(), wheel.getPrizes())); + this.client.sendResponse(new WheelRecentWinsComposer(wheel.getRecentWins(50))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelSpinEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelSpinEvent.java new file mode 100644 index 00000000..286de1f3 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelSpinEvent.java @@ -0,0 +1,39 @@ +package com.eu.habbo.messages.incoming.wheel; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.wheel.WheelManager; +import com.eu.habbo.habbohotel.wheel.WheelPrize; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer; +import com.eu.habbo.messages.outgoing.wheel.WheelRecentWinsComposer; +import com.eu.habbo.messages.outgoing.wheel.WheelResultComposer; + +public class WheelSpinEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1500; + } + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + if (habbo == null) return; + + WheelManager wheel = Emulator.getGameEnvironment().getWheelManager(); + WheelPrize prize = wheel.spin(habbo); + + if (prize != null) { + this.client.sendResponse(new WheelResultComposer(prize.id)); + } + + // Refresh the balance either way so the client unlocks the wheel. + this.client.sendResponse(new WheelDataComposer( + wheel.getUserState(habbo.getHabboInfo().getId()), + wheel.getSpinCost(), wheel.getSpinCostType(), wheel.getPrizes())); + + if (prize != null) { + this.client.sendResponse(new WheelRecentWinsComposer(wheel.getRecentWins(50))); + } + } +} 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 3e6bf2ec..6eb8c3c6 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 @@ -594,4 +594,11 @@ public class Outgoing { public static final int HousekeepingDashboardComposer = 9204; public static final int HousekeepingActionLogComposer = 9205; + // Custom features — IDs 9400+ reserved + public static final int RareValuesComposer = 9400; + public static final int WheelDataComposer = 9401; + public static final int WheelResultComposer = 9402; + public static final int WheelRecentWinsComposer = 9403; + public static final int WheelAdminPrizesComposer = 9404; + } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rarevalues/RareValuesComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rarevalues/RareValuesComposer.java new file mode 100644 index 00000000..f713c8c9 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rarevalues/RareValuesComposer.java @@ -0,0 +1,35 @@ +package com.eu.habbo.messages.outgoing.rarevalues; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; +import gnu.trove.iterator.TIntObjectIterator; +import gnu.trove.map.TIntObjectMap; + +// Sends the full spriteId -> value map to the client. Consumed by the toolbar +// price guide and the furni infostand "value" line. See CatalogManager#loadFurnitureValues. +public class RareValuesComposer extends MessageComposer { + private final TIntObjectMap values; + + public RareValuesComposer(TIntObjectMap values) { + this.values = values; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.RareValuesComposer); + this.response.appendInt(this.values.size()); + + TIntObjectIterator iterator = this.values.iterator(); + while (iterator.hasNext()) { + iterator.advance(); + int[] value = iterator.value(); + this.response.appendInt(iterator.key()); // spriteId + this.response.appendInt(value[0]); // credits + this.response.appendInt(value[1]); // points + this.response.appendInt(value[2]); // pointsType + } + + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelAdminPrizesComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelAdminPrizesComposer.java new file mode 100644 index 00000000..22196ac3 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelAdminPrizesComposer.java @@ -0,0 +1,34 @@ +package com.eu.habbo.messages.outgoing.wheel; + +import com.eu.habbo.habbohotel.wheel.WheelPrize; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +// Raw editable prize list for the in-client admin editor (sends value/amount/ +// pointsType as stored, unlike WheelDataComposer which resolves icons for players). +public class WheelAdminPrizesComposer extends MessageComposer { + private final List prizes; + + public WheelAdminPrizesComposer(List prizes) { + this.prizes = prizes; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.WheelAdminPrizesComposer); + this.response.appendInt(this.prizes.size()); + for (WheelPrize prize : this.prizes) { + this.response.appendInt(prize.id); + this.response.appendString(prize.type); + this.response.appendString(prize.value == null ? "" : prize.value); + this.response.appendInt(prize.amount); + this.response.appendInt(prize.pointsType); + this.response.appendInt(prize.weight); + this.response.appendString(prize.label == null ? "" : prize.label); + } + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelDataComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelDataComposer.java new file mode 100644 index 00000000..486c4a5c --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelDataComposer.java @@ -0,0 +1,46 @@ +package com.eu.habbo.messages.outgoing.wheel; + +import com.eu.habbo.habbohotel.wheel.WheelPrize; +import com.eu.habbo.habbohotel.wheel.WheelUserState; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +// User spin balance + cost + the full prize list (one entry per slice). +public class WheelDataComposer extends MessageComposer { + private final WheelUserState state; + private final int spinCost; + private final int spinCostType; + private final List prizes; + + public WheelDataComposer(WheelUserState state, int spinCost, int spinCostType, List prizes) { + this.state = state; + this.spinCost = spinCost; + this.spinCostType = spinCostType; + this.prizes = prizes; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.WheelDataComposer); + this.response.appendInt(this.state.freeSpins); + this.response.appendInt(this.state.extraSpins); + this.response.appendInt(this.spinCost); + this.response.appendInt(this.spinCostType); + + this.response.appendInt(this.prizes.size()); + for (WheelPrize prize : this.prizes) { + this.response.appendInt(prize.id); + this.response.appendString(prize.type); + this.response.appendInt(prize.spriteId); // item only, else 0 + this.response.appendString(prize.badgeCode()); // badge only, else "" + this.response.appendInt(prize.amount); + this.response.appendInt(prize.pointsType); + this.response.appendString(prize.label == null ? "" : prize.label); + } + + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelRecentWinsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelRecentWinsComposer.java new file mode 100644 index 00000000..b4eac3e7 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelRecentWinsComposer.java @@ -0,0 +1,29 @@ +package com.eu.habbo.messages.outgoing.wheel; + +import com.eu.habbo.habbohotel.wheel.WheelRecentWin; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +// "Latest winners" list: username + look (for the headshot) + prize label. +public class WheelRecentWinsComposer extends MessageComposer { + private final List wins; + + public WheelRecentWinsComposer(List wins) { + this.wins = wins; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.WheelRecentWinsComposer); + this.response.appendInt(this.wins.size()); + for (WheelRecentWin win : this.wins) { + this.response.appendString(win.username); + this.response.appendString(win.look); + this.response.appendString(win.prizeLabel); + } + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelResultComposer.java new file mode 100644 index 00000000..76aeeed2 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/wheel/WheelResultComposer.java @@ -0,0 +1,22 @@ +package com.eu.habbo.messages.outgoing.wheel; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +// The winning prize id. The client animates the wheel to that slice; the reward +// was already granted server-side. +public class WheelResultComposer extends MessageComposer { + private final int prizeId; + + public WheelResultComposer(int prizeId) { + this.prizeId = prizeId; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.WheelResultComposer); + this.response.appendInt(this.prizeId); + return this.response; + } +} From 10a2b2b872419eaa9763fb74a525d7fea775fd7e Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:03:27 +0200 Subject: [PATCH 2/5] feat: soundboard (room-scoped custom audio pads) Server side of the soundboard feature: - rooms.soundboard_enabled flag + soundboard_sounds table (self-bootstraps at boot via SoundboardManager; migration 021 seeds up-front) - SoundboardManager loads enabled sounds and persists the per-room flag - SoundboardPlayEvent broadcasts the pressed pad to everyone in the room - SoundboardSetEnabledEvent (owner/staff) toggles the room flag and pushes refreshed settings - settings (flag + sound list) sent on room enter, alongside YouTube --- .../Own_Database_RunFirst/021_soundboard.sql | 15 ++++ .../eu/habbo/habbohotel/GameEnvironment.java | 7 ++ .../com/eu/habbo/habbohotel/rooms/Room.java | 4 + .../habbo/habbohotel/rooms/RoomManager.java | 4 + .../soundboard/SoundboardManager.java | 78 +++++++++++++++++++ .../soundboard/SoundboardSound.java | 17 ++++ .../com/eu/habbo/messages/PacketManager.java | 3 + .../eu/habbo/messages/incoming/Incoming.java | 2 + .../soundboard/SoundboardPlayEvent.java | 31 ++++++++ .../soundboard/SoundboardSetEnabledEvent.java | 37 +++++++++ .../eu/habbo/messages/outgoing/Outgoing.java | 2 + .../soundboard/SoundboardPlayComposer.java | 27 +++++++ .../SoundboardSettingsComposer.java | 33 ++++++++ 13 files changed, 260 insertions(+) create mode 100644 Database Updates/Own_Database_RunFirst/021_soundboard.sql create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardManager.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardSound.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardPlayEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardSetEnabledEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardPlayComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardSettingsComposer.java diff --git a/Database Updates/Own_Database_RunFirst/021_soundboard.sql b/Database Updates/Own_Database_RunFirst/021_soundboard.sql new file mode 100644 index 00000000..0cb9e167 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/021_soundboard.sql @@ -0,0 +1,15 @@ +-- Soundboard +-- The room flag column + sounds table are also created at boot by +-- SoundboardManager (ALTER ... ADD COLUMN IF NOT EXISTS / CREATE TABLE IF NOT +-- EXISTS), so applying this file is only needed to seed sounds up-front. + +ALTER TABLE `rooms` ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS `soundboard_sounds` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `name` VARCHAR(64) NOT NULL DEFAULT '', -- pad label shown in the client + `url` VARCHAR(255) NOT NULL DEFAULT '', -- audio url (uploaded via CMS, like custom badges) + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java index 0a09636f..8fdb7842 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java @@ -7,6 +7,7 @@ import com.eu.habbo.habbohotel.bots.BotManager; import com.eu.habbo.habbohotel.campaign.calendar.CalendarManager; import com.eu.habbo.habbohotel.catalog.CatalogManager; import com.eu.habbo.habbohotel.wheel.WheelManager; +import com.eu.habbo.habbohotel.soundboard.SoundboardManager; import com.eu.habbo.habbohotel.commands.CommandHandler; import com.eu.habbo.habbohotel.crafting.CraftingManager; import com.eu.habbo.habbohotel.guides.GuideManager; @@ -66,6 +67,7 @@ public class GameEnvironment { private CustomBadgeManager customBadgeManager; private InfostandBackgroundManager infostandBackgroundManager; private WheelManager wheelManager; + private SoundboardManager soundboardManager; public void load() throws Exception { LOGGER.info("GameEnvironment -> Loading..."); @@ -96,6 +98,7 @@ public class GameEnvironment { this.customBadgeManager = new CustomBadgeManager(); this.infostandBackgroundManager = new InfostandBackgroundManager(); this.wheelManager = new WheelManager(); + this.soundboardManager = new SoundboardManager(); this.roomManager.loadPublicRooms(); this.navigatorManager.loadNavigator(); @@ -163,6 +166,10 @@ public class GameEnvironment { return this.wheelManager; } + public SoundboardManager getSoundboardManager() { + return this.soundboardManager; + } + public HotelViewManager getHotelViewManager() { return this.hotelViewManager; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java index 8d693678..0e4f2d87 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java @@ -197,6 +197,7 @@ public class Room implements Comparable, ISerialize, Runnable { private int wiredInspectMask = WIRED_ACCESS_DEFAULT_INSPECT_MASK; private int wiredModifyMask = WIRED_ACCESS_DEFAULT_MODIFY_MASK; private boolean youtubeEnabled = false; + private boolean soundboardEnabled = false; private String youtubeCurrentVideo = ""; private String youtubeSenderName = ""; private final java.util.List youtubePlaylist = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -204,6 +205,8 @@ public class Room implements Comparable, ISerialize, Runnable { public boolean isYoutubeEnabled() { return this.youtubeEnabled; } public void setYoutubeEnabled(boolean enabled) { this.youtubeEnabled = enabled; } + public boolean isSoundboardEnabled() { return this.soundboardEnabled; } + public void setSoundboardEnabled(boolean enabled) { this.soundboardEnabled = enabled; } public String getYoutubeCurrentVideo() { return this.youtubeCurrentVideo; } public String getYoutubeSenderName() { return this.youtubeSenderName; } public java.util.List getYoutubePlaylist() { return this.youtubePlaylist; } @@ -250,6 +253,7 @@ public class Room implements Comparable, ISerialize, Runnable { this.allowWalkthrough = set.getBoolean("allow_walkthrough"); this.hideWall = set.getBoolean("allow_hidewall"); try { this.youtubeEnabled = set.getBoolean("youtube_enabled"); } catch (Exception e) { this.youtubeEnabled = false; } + try { this.soundboardEnabled = set.getBoolean("soundboard_enabled"); } catch (Exception e) { this.soundboardEnabled = false; } this.chatMode = set.getInt("chat_mode"); this.chatWeight = set.getInt("chat_weight"); this.chatSpeed = set.getInt("chat_speed"); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java index a9699d0f..dd3a29e6 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java @@ -1020,6 +1020,10 @@ public class RoomManager { room.getYoutubeWatchers()).compose()); } + habbo.getClient().sendResponse(new com.eu.habbo.messages.outgoing.soundboard.SoundboardSettingsComposer( + room.isSoundboardEnabled(), + Emulator.getGameEnvironment().getSoundboardManager().getSounds()).compose()); + WiredManager.triggerUserEntersRoom(room, habbo.getRoomUnit()); room.habboEntered(habbo); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardManager.java new file mode 100644 index 00000000..d263af39 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardManager.java @@ -0,0 +1,78 @@ +package com.eu.habbo.habbohotel.soundboard; + +import com.eu.habbo.Emulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +public class SoundboardManager { + private static final Logger LOGGER = LoggerFactory.getLogger(SoundboardManager.class); + + private final List sounds = new ArrayList<>(); + + public SoundboardManager() { + long millis = System.currentTimeMillis(); + this.bootstrap(); + this.reload(); + LOGGER.info("Soundboard Manager -> Loaded! ({} MS, {} sounds)", System.currentTimeMillis() - millis, this.sounds.size()); + } + + // Self-bootstrap: room flag column + sounds table, so the feature works even + // before the manual migration is applied. + private void bootstrap() { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("ALTER TABLE `rooms` ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0"); + statement.execute("CREATE TABLE IF NOT EXISTS `soundboard_sounds` (" + + "`id` INT(11) NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL DEFAULT '', " + + "`url` VARCHAR(255) NOT NULL DEFAULT '', `enabled` TINYINT(1) NOT NULL DEFAULT 1, " + + "`sort_order` INT(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } catch (SQLException e) { + LOGGER.error("Failed to bootstrap soundboard schema", e); + } + } + + public void reload() { + this.sounds.clear(); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT id, name, url FROM soundboard_sounds WHERE enabled = 1 ORDER BY sort_order ASC, id ASC"); + ResultSet set = statement.executeQuery()) { + while (set.next()) { + this.sounds.add(new SoundboardSound(set)); + } + } catch (SQLException e) { + LOGGER.error("Failed to load soundboard sounds", e); + } + } + + public List getSounds() { + return this.sounds; + } + + public SoundboardSound getSound(int id) { + for (SoundboardSound sound : this.sounds) { + if (sound.id == id) return sound; + } + return null; + } + + // Owner toggle — persists the room flag with a dedicated UPDATE (kept out of + // the big room-settings save to avoid touching that statement). + public void setRoomEnabled(int roomId, boolean enabled) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("UPDATE rooms SET soundboard_enabled = ? WHERE id = ? LIMIT 1")) { + statement.setString(1, enabled ? "1" : "0"); + statement.setInt(2, roomId); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Failed to set soundboard_enabled for room {}", roomId, e); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardSound.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardSound.java new file mode 100644 index 00000000..71a388ad --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/soundboard/SoundboardSound.java @@ -0,0 +1,17 @@ +package com.eu.habbo.habbohotel.soundboard; + +import java.sql.ResultSet; +import java.sql.SQLException; + +// One soundboard pad: a named audio clip served from a URL (uploaded via the CMS). +public class SoundboardSound { + public final int id; + public final String name; + public final String url; + + public SoundboardSound(ResultSet set) throws SQLException { + this.id = set.getInt("id"); + this.name = set.getString("name"); + this.url = set.getString("url"); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index 7fbfec63..0b54d15e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -753,5 +753,8 @@ public class PacketManager { this.registerHandler(Incoming.WheelBuySpinEvent, com.eu.habbo.messages.incoming.wheel.WheelBuySpinEvent.class); this.registerHandler(Incoming.WheelAdminGetPrizesEvent, com.eu.habbo.messages.incoming.wheel.WheelAdminGetPrizesEvent.class); this.registerHandler(Incoming.WheelAdminSavePrizesEvent, com.eu.habbo.messages.incoming.wheel.WheelAdminSavePrizesEvent.class); + + this.registerHandler(Incoming.SoundboardPlayEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardPlayEvent.class); + this.registerHandler(Incoming.SoundboardSetEnabledEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardSetEnabledEvent.class); } } 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 52b32bb2..9d372215 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 @@ -494,4 +494,6 @@ public class Incoming { public static final int WheelBuySpinEvent = 9303; public static final int WheelAdminGetPrizesEvent = 9304; public static final int WheelAdminSavePrizesEvent = 9305; + public static final int SoundboardPlayEvent = 9306; + public static final int SoundboardSetEnabledEvent = 9307; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardPlayEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardPlayEvent.java new file mode 100644 index 00000000..eb227160 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardPlayEvent.java @@ -0,0 +1,31 @@ +package com.eu.habbo.messages.incoming.soundboard; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.soundboard.SoundboardSound; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.soundboard.SoundboardPlayComposer; + +public class SoundboardPlayEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 250; + } + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + if (habbo == null) return; + + Room room = this.currentRoom(); + if (room == null || !room.isSoundboardEnabled()) return; + + int soundId = this.packet.readInt(); + SoundboardSound sound = Emulator.getGameEnvironment().getSoundboardManager().getSound(soundId); + if (sound == null) return; + + // Broadcast to everyone in the room. + room.sendComposer(new SoundboardPlayComposer(sound.id, sound.url, habbo.getHabboInfo().getUsername()).compose()); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardSetEnabledEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardSetEnabledEvent.java new file mode 100644 index 00000000..ab507007 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/soundboard/SoundboardSetEnabledEvent.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.incoming.soundboard; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.soundboard.SoundboardSettingsComposer; + +public class SoundboardSetEnabledEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1000; + } + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + if (habbo == null) return; + + Room room = this.currentRoom(); + if (room == null) return; + + // Only the room owner (or staff) may toggle the soundboard for the room. + boolean isOwner = room.getOwnerId() == habbo.getHabboInfo().getId(); + if (!isOwner && !habbo.hasPermission(Permission.ACC_SUPPORTTOOL)) return; + + boolean enabled = this.packet.readInt() == 1; + + room.setSoundboardEnabled(enabled); + Emulator.getGameEnvironment().getSoundboardManager().setRoomEnabled(room.getId(), enabled); + + // Push the refreshed settings (flag + sound list) to everyone in the room + // so the toolbar icon appears/disappears live. + room.sendComposer(new SoundboardSettingsComposer(enabled, Emulator.getGameEnvironment().getSoundboardManager().getSounds()).compose()); + } +} 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 6eb8c3c6..882365a4 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 @@ -600,5 +600,7 @@ public class Outgoing { public static final int WheelResultComposer = 9402; public static final int WheelRecentWinsComposer = 9403; public static final int WheelAdminPrizesComposer = 9404; + public static final int SoundboardSettingsComposer = 9405; + public static final int SoundboardPlayComposer = 9406; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardPlayComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardPlayComposer.java new file mode 100644 index 00000000..745205ad --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardPlayComposer.java @@ -0,0 +1,27 @@ +package com.eu.habbo.messages.outgoing.soundboard; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +// Broadcast to everyone in the room when a pad is pressed — they all play the clip. +public class SoundboardPlayComposer extends MessageComposer { + private final int soundId; + private final String url; + private final String username; + + public SoundboardPlayComposer(int soundId, String url, String username) { + this.soundId = soundId; + this.url = url != null ? url : ""; + this.username = username != null ? username : ""; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.SoundboardPlayComposer); + this.response.appendInt(this.soundId); + this.response.appendString(this.url); + this.response.appendString(this.username); + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardSettingsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardSettingsComposer.java new file mode 100644 index 00000000..7293e01f --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/soundboard/SoundboardSettingsComposer.java @@ -0,0 +1,33 @@ +package com.eu.habbo.messages.outgoing.soundboard; + +import com.eu.habbo.habbohotel.soundboard.SoundboardSound; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +// Sent on room enter (and on toggle): whether the soundboard is active in this +// room + the available pads. The client shows the toolbar icon only if enabled. +public class SoundboardSettingsComposer extends MessageComposer { + private final boolean enabled; + private final List sounds; + + public SoundboardSettingsComposer(boolean enabled, List sounds) { + this.enabled = enabled; + this.sounds = sounds; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.SoundboardSettingsComposer); + this.response.appendBoolean(this.enabled); + this.response.appendInt(this.sounds.size()); + for (SoundboardSound sound : this.sounds) { + this.response.appendInt(sound.id); + this.response.appendString(sound.name); + this.response.appendString(sound.url); + } + return this.response; + } +} From e626a7fc50494db7265f05e13ef3dd26ee35a82b Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 12:33:50 +0200 Subject: [PATCH 3/5] feat: version string tied to project version + "Extended" title The :about / :info hotel-info title was hardcoded ("Arcturus Morningstar 4.1.0") and drifted from the real build. Now Emulator.version reads the jar manifest's Implementation-Version (= ${project.version}, added via the assembly plugin) and falls back to MAJOR.MINOR.BUILD only outside a jar. Title becomes "Arcturus Morningstar Extended " (e.g. 4.2.24). --- Emulator/pom.xml | 1 + Emulator/src/main/java/com/eu/habbo/Emulator.java | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 1f4ed186..ff8e57c4 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -38,6 +38,7 @@ com.eu.habbo.Emulator + true diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 1a9fe716..de1a4e86 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -39,12 +39,23 @@ public final class Emulator { 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"); + // 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. public final static int MAJOR = 4; public final static int MINOR = 1; public final static int BUILD = 0; public final static String PREVIEW = ""; - public static final String version = "Arcturus Morningstar" + " " + MAJOR + "." + MINOR + "." + BUILD + " " + PREVIEW; + // Tied to the Maven project version: read from the jar manifest + // (Implementation-Version = ${project.version}, see pom assembly plugin). + private static String resolveVersionNumber() { + String implementation = Emulator.class.getPackage().getImplementationVersion(); + if (implementation != null && !implementation.isEmpty()) return implementation; + String fallback = MAJOR + "." + MINOR + "." + BUILD; + return PREVIEW.isEmpty() ? fallback : fallback + " " + PREVIEW; + } + + public static final String version = "Arcturus Morningstar Extended " + resolveVersionNumber(); private static final String logo = "\n" + "███╗ ███╗ ██████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ███████╗████████╗ █████╗ ██████╗ \n" + From 9705b3e42a0d26045f1f5dbc750db39d458391e4 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 13:00:02 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=86=95=20Added=20the=20option=20turn?= =?UTF-8?q?=20in=20menu=20for=20BOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eu/habbo/habbohotel/bots/Bot.java | 32 +++++++++++++------ .../eu/habbo/habbohotel/bots/FrankBot.java | 10 ++++-- .../rooms/bots/BotSaveSettingsEvent.java | 28 ++++++++++++---- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java index 9123c338..d373cfa5 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java @@ -189,11 +189,7 @@ public class Bot implements Runnable { int timeOut = Emulator.getRandom().nextInt(20) * 2; this.roomUnit.setWalkTimeOut((timeOut < 10 ? 5 : timeOut) + Emulator.getIntUnixTimestamp()); } - }/* else { - for (RoomTile t : this.room.getLayout().getTilesAround(this.room.getLayout().getTile(this.getRoomUnit().getX(), this.getRoomUnit().getY()))) { - WiredManager.handle(WiredTriggerType.BOT_REACHED_STF, this.roomUnit, this.room, this.room.getItemsAt(t).toArray()); - } - }*/ + } } if (!this.chatLines.isEmpty() && this.chatTimeOut <= Emulator.getIntUnixTimestamp() && this.chatAuto) { @@ -218,7 +214,7 @@ public class Bot implements Runnable { } else { this.lastChatIndex++; if (this.lastChatIndex >= this.chatLines.size()) { - this.lastChatIndex = 0; // start from scratch :-3 + this.lastChatIndex = 0; } } @@ -310,9 +306,6 @@ public class Bot implements Runnable { public void setName(String name) { this.name = name; this.needsUpdate = true; - - //if(this.room != null) - //this.room.sendComposer(new ChangeNameUpdatedComposer(this.getRoomUnit(), this.getName()).compose()); } public String getMotto() { @@ -539,9 +532,28 @@ public class Bot implements Runnable { } } - private static final short[] DEFAULT_OWNER_ACTION_IDS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + private static final short[] DEFAULT_OWNER_ACTION_IDS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11}; + + public static final int ACTION_ROTATE = 11; + + private static final long MIN_OWNER_ACTION_INTERVAL_MS = 200L; + + private volatile long lastOwnerActionAt; public short[] getOwnerActionIds() { return DEFAULT_OWNER_ACTION_IDS; } + + public synchronized boolean tryAcquireOwnerActionSlot() { + long now = System.currentTimeMillis(); + if (now - this.lastOwnerActionAt < MIN_OWNER_ACTION_INTERVAL_MS) { + return false; + } + this.lastOwnerActionAt = now; + return true; + } + + public void onPostOwnerAction(int actionId) { + // no-op default + } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java index a373f0fc..2a844cb9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java @@ -23,7 +23,6 @@ import java.util.regex.Pattern; public class FrankBot extends ButlerBot { private static final Logger LOGGER = LoggerFactory.getLogger(FrankBot.class); - public static final String BOT_TYPE = "frank"; public static final String PERMISSION_USE = "acc_bot_frank"; private static final String KEY_DOOR_LINES = "__door_lines"; @@ -75,13 +74,20 @@ public class FrankBot extends ButlerBot { } } - private static final short[] FRANK_OWNER_ACTIONS = new short[0]; + private static final short[] FRANK_OWNER_ACTIONS = { (short) Bot.ACTION_ROTATE }; @Override public short[] getOwnerActionIds() { return FRANK_OWNER_ACTIONS; } + @Override + public void onPostOwnerAction(int actionId) { + if (actionId == ACTION_ROTATE && this.getRoomUnit() != null) { + this.homeRotation = this.getRoomUnit().getBodyRotation(); + } + } + public static void initialise() { chatResponses.clear(); doorLines = DEFAULT_DOOR_LINES; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java index 6d7c8d21..23a99af3 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java @@ -5,11 +5,13 @@ import com.eu.habbo.habbohotel.bots.Bot; import com.eu.habbo.habbohotel.bots.BotManager; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.rooms.RoomUserRotation; import com.eu.habbo.habbohotel.users.DanceType; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.generic.alerts.BotErrorComposer; import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDanceComposer; import com.eu.habbo.messages.outgoing.rooms.users.RoomUserNameChangedComposer; +import com.eu.habbo.messages.outgoing.rooms.users.RoomUserStatusComposer; import com.eu.habbo.messages.outgoing.rooms.users.RoomUsersComposer; import com.eu.habbo.plugin.events.bots.BotSavedChatEvent; import com.eu.habbo.plugin.events.bots.BotSavedLookEvent; @@ -28,16 +30,20 @@ public class BotSaveSettingsEvent extends MessageHandler { if (room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { int botId = this.packet.readInt(); - Bot bot = room.getBot(Math.abs(botId)); - if (bot == null) return; - - if (bot.getOwnerActionIds().length == 0) - return; - int settingId = this.packet.readInt(); + boolean allowed = false; + for (short a : bot.getOwnerActionIds()) { + if (a == settingId) { + allowed = true; + break; + } + } + if (!allowed) return; + + if (!bot.tryAcquireOwnerActionSlot()) return; switch (settingId) { case 1: @@ -163,8 +169,18 @@ public class BotSaveSettingsEvent extends MessageHandler { bot.needsUpdate(true); room.sendComposer(new RoomUsersComposer(bot).compose()); break; + case Bot.ACTION_ROTATE: + if (bot.getRoomUnit() == null) break; + int next = (bot.getRoomUnit().getBodyRotation().getValue() + 2) % 8; + RoomUserRotation rotation = RoomUserRotation.fromValue(next); + bot.getRoomUnit().setRotation(rotation); + bot.needsUpdate(true); + room.sendComposer(new RoomUserStatusComposer(bot.getRoomUnit()).compose()); + break; } + bot.onPostOwnerAction(settingId); + if (bot.needsUpdate()) { Emulator.getThreading().run(bot); } From 1ba2e43d4d185fee589c089bb691af7f0dafa3da Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 16:36:22 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=86=99=20Wheel=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...l.sql => 008_soundboard_fortune_wheel.sql} | 42 +++-- .../Own_Database_RunFirst/021_soundboard.sql | 15 -- .../habbo/habbohotel/wheel/WheelManager.java | 159 +++++++++++------- .../wheel/WheelAdminGetPrizesEvent.java | 5 +- .../wheel/WheelAdminSavePrizesEvent.java | 9 +- 5 files changed, 138 insertions(+), 92 deletions(-) rename Database Updates/{Own_Database_RunFirst/020_fortune_wheel.sql => 008_soundboard_fortune_wheel.sql} (67%) delete mode 100644 Database Updates/Own_Database_RunFirst/021_soundboard.sql diff --git a/Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql b/Database Updates/008_soundboard_fortune_wheel.sql similarity index 67% rename from Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql rename to Database Updates/008_soundboard_fortune_wheel.sql index e158425c..b5dba5ba 100644 --- a/Database Updates/Own_Database_RunFirst/020_fortune_wheel.sql +++ b/Database Updates/008_soundboard_fortune_wheel.sql @@ -1,3 +1,18 @@ +-- Soundboard +-- The room flag column + sounds table are also created at boot by + +ALTER TABLE `rooms` ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS `soundboard_sounds` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `name` VARCHAR(64) NOT NULL DEFAULT '', -- pad label shown in the client + `url` VARCHAR(255) NOT NULL DEFAULT '', -- audio url (uploaded via CMS, like custom badges) + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + -- Fortune Wheel -- Tables are also created at boot by WheelManager (CREATE TABLE IF NOT EXISTS), -- so applying this file is only needed to seed prizes + settings. @@ -44,15 +59,20 @@ INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES ('wheel.spin_cost_type', '5', 'Fortune wheel: currency type for the spin cost (5 = diamonds; -1 = credits).') ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); --- Example prizes (currency / spin / nothing don't reference furniture ids). --- Add `item`/`badge` rows with your own ids: e.g. --- INSERT INTO wheel_prizes (type, value, amount, weight, label, sort_order) VALUES ('item','',1,5,'Raro',1); --- INSERT INTO wheel_prizes (type, value, amount, weight, label, sort_order) VALUES ('badge','',1,5,'Distintivo',2); + INSERT INTO `wheel_prizes` (`type`, `amount`, `points_type`, `weight`, `label`, `sort_order`) VALUES - ('points', 25, 5, 20, '25 diamanti', 10), - ('points', 50, 5, 12, '50 diamanti', 11), - ('points', 200, 5, 3, '200 diamanti', 12), - ('credits', 100, 0, 15, '100 crediti', 13), - ('spin', 1, 0, 15, '1 Giro Extra', 14), - ('spin', 2, 0, 6, '2 Giri Extra', 15), - ('nothing', 0, 0, 29, 'Nulla', 16); + ('points',25, 5, 20, '25 diamonds',1), + ('points',50, 5, 12, '50 diamonds',2), + ('points',200, 5, 3, '200 diamonds',3), + ('credits',100, 0, 15, '100 credits',4), + ('spin',1, 0, 15, '1 Extra spin', 5), + ('spin',2, 0, 6, '2 Extra spins',6), + ('nothing',0, 0, 29, 'Oh to bad!',7); + +INSERT INTO `permission_definitions` + (`permission_key`, `max_value`, `comment`, + `rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`) +VALUES + ('acc_wheeladmin', 1, 'Required to open the Fortune Wheel settings popup and edit prize rows.', + 0, 0, 0, 0, 0, 0, 1) +ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); diff --git a/Database Updates/Own_Database_RunFirst/021_soundboard.sql b/Database Updates/Own_Database_RunFirst/021_soundboard.sql deleted file mode 100644 index 0cb9e167..00000000 --- a/Database Updates/Own_Database_RunFirst/021_soundboard.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Soundboard --- The room flag column + sounds table are also created at boot by --- SoundboardManager (ALTER ... ADD COLUMN IF NOT EXISTS / CREATE TABLE IF NOT --- EXISTS), so applying this file is only needed to seed sounds up-front. - -ALTER TABLE `rooms` ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0; - -CREATE TABLE IF NOT EXISTS `soundboard_sounds` ( - `id` INT(11) NOT NULL AUTO_INCREMENT, - `name` VARCHAR(64) NOT NULL DEFAULT '', -- pad label shown in the client - `url` VARCHAR(255) NOT NULL DEFAULT '', -- audio url (uploaded via CMS, like custom badges) - `enabled` TINYINT(1) NOT NULL DEFAULT 1, - `sort_order` INT(11) NOT NULL DEFAULT 0, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java index e5ccc316..ba577dc4 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wheel/WheelManager.java @@ -12,9 +12,10 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Statement; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; public class WheelManager { @@ -22,15 +23,28 @@ public class WheelManager { private static final int RECENT_KEEP = 50; private static final int SECONDS_PER_DAY = 86400; + public static final Set VALID_PRIZE_TYPES = Set.of( + "credits", "points", "spin", "item", "badge", "nothing"); + public static final int MAX_PRIZES_PER_SAVE = 64; + public static final int MAX_STRING_LEN = 64; + public static final int MAX_PRIZE_AMOUNT = 1_000_000; + public static final int MAX_ITEM_QUANTITY = 100; + public static final int MAX_WEIGHT = 1_000_000; + public static final int MAX_EXTRA_SPINS = 10_000; + private static final long MIN_SPIN_INTERVAL_MS = 1500L; + private final List prizes = new ArrayList<>(); private int totalWeight = 0; private int freeSpinsPerDay = 1; private int spinCost = 50; private int spinCostType = 5; + private final ConcurrentHashMap lastSpinAt = new ConcurrentHashMap<>(); + private final ConcurrentHashMap userStateCache = new ConcurrentHashMap<>(); + private final java.util.concurrent.CopyOnWriteArrayList recentWinsCache = new java.util.concurrent.CopyOnWriteArrayList<>(); + public WheelManager() { long millis = System.currentTimeMillis(); - this.createTables(); this.reload(); LOGGER.info("Wheel Manager -> Loaded! ({} MS)", System.currentTimeMillis() - millis); } @@ -38,35 +52,7 @@ public class WheelManager { public void reload() { this.loadSettings(); this.loadPrizes(); - } - - private void createTables() { - final String[] ddl = { - "CREATE TABLE IF NOT EXISTS `wheel_prizes` (" + - "`id` INT(11) NOT NULL AUTO_INCREMENT, `type` VARCHAR(16) NOT NULL DEFAULT 'nothing', " + - "`value` VARCHAR(64) NOT NULL DEFAULT '', `amount` INT(11) NOT NULL DEFAULT 1, " + - "`points_type` INT(11) NOT NULL DEFAULT 5, `weight` INT(11) NOT NULL DEFAULT 1, " + - "`label` VARCHAR(64) NOT NULL DEFAULT '', `enabled` TINYINT(1) NOT NULL DEFAULT 1, " + - "`sort_order` INT(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", - "CREATE TABLE IF NOT EXISTS `wheel_user_state` (" + - "`user_id` INT(11) NOT NULL, `free_spins` INT(11) NOT NULL DEFAULT 0, " + - "`extra_spins` INT(11) NOT NULL DEFAULT 0, `last_reset` INT(11) NOT NULL DEFAULT 0, " + - "PRIMARY KEY (`user_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", - "CREATE TABLE IF NOT EXISTS `wheel_recent_wins` (" + - "`id` INT(11) NOT NULL AUTO_INCREMENT, `user_id` INT(11) NOT NULL, " + - "`username` VARCHAR(64) NOT NULL DEFAULT '', `look` VARCHAR(255) NOT NULL DEFAULT '', " + - "`prize_label` VARCHAR(64) NOT NULL DEFAULT '', `won_at` INT(11) NOT NULL DEFAULT 0, " + - "PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" - }; - - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); - Statement statement = connection.createStatement()) { - for (String query : ddl) { - statement.execute(query); - } - } catch (SQLException e) { - LOGGER.error("Failed to create fortune wheel tables", e); - } + this.loadRecentWins(); } private void loadSettings() { @@ -108,8 +94,19 @@ public class WheelManager { return Emulator.getIntUnixTimestamp() / SECONDS_PER_DAY; } - // Reads the user's spin balance, applying the lazy daily reset and creating the row if missing. - public WheelUserState getUserState(int userId) { + public synchronized WheelUserState getUserState(int userId) { + int today = this.today(); + WheelUserState cached = this.userStateCache.get(userId); + + if (cached != null) { + if (cached.lastReset != today) { + cached.freeSpins = this.freeSpinsPerDay; + cached.lastReset = today; + this.persistUserState(userId, cached); + } + return cached; + } + WheelUserState state = new WheelUserState(); boolean exists = false; @@ -128,7 +125,6 @@ public class WheelManager { LOGGER.error("Failed to read wheel state for user {}", userId, e); } - int today = this.today(); if (!exists) { state.freeSpins = this.freeSpinsPerDay; state.extraSpins = 0; @@ -140,6 +136,7 @@ public class WheelManager { this.persistUserState(userId, state); } + this.userStateCache.put(userId, state); return state; } @@ -158,10 +155,13 @@ public class WheelManager { } } - // Consumes a spin (free first, then extra), picks a weighted prize, grants it and records the win. - // Returns the prize, or null if the user has no spins or no prizes are configured. public synchronized WheelPrize spin(Habbo habbo) { int userId = habbo.getHabboInfo().getId(); + long now = System.currentTimeMillis(); + Long last = this.lastSpinAt.get(userId); + if (last != null && (now - last) < MIN_SPIN_INTERVAL_MS) return null; + this.lastSpinAt.put(userId, now); + WheelUserState state = this.getUserState(userId); boolean usedFree; @@ -177,15 +177,12 @@ public class WheelManager { WheelPrize prize = this.pickWeighted(); if (prize == null) { - // No prizes configured — refund the spin we just consumed. if (usedFree) state.freeSpins++; else state.extraSpins++; return null; } this.giveReward(habbo, prize, state); this.persistUserState(userId, state); - - // Record every spin (including "nothing") so the live feed shows all activity. this.recordWin(habbo, prize); return prize; @@ -204,21 +201,26 @@ public class WheelManager { } private void giveReward(Habbo habbo, WheelPrize prize, WheelUserState state) { + int amount = Math.max(0, Math.min(prize.amount, MAX_PRIZE_AMOUNT)); + switch (prize.type) { case "credits": - habbo.giveCredits(prize.amount); + if (amount > 0) habbo.giveCredits(amount); break; case "points": - habbo.givePoints(prize.pointsType, prize.amount); + if (amount > 0) habbo.givePoints(prize.pointsType, amount); break; case "spin": - state.extraSpins += Math.max(0, prize.amount); + int room = Math.max(0, MAX_EXTRA_SPINS - state.extraSpins); + state.extraSpins += Math.min(amount, room); break; case "item": - this.giveItem(habbo, prize); + this.giveItem(habbo, prize, Math.min(amount, MAX_ITEM_QUANTITY)); break; case "badge": - habbo.addBadge(prize.value, "Fortune Wheel"); + if (prize.value != null && !prize.value.isEmpty()) { + habbo.addBadge(prize.value, "Fortune Wheel"); + } break; case "nothing": default: @@ -226,7 +228,9 @@ public class WheelManager { } } - private void giveItem(Habbo habbo, WheelPrize prize) { + private void giveItem(Habbo habbo, WheelPrize prize, int quantity) { + if (quantity <= 0 || prize.value == null) return; + int baseId; try { baseId = Integer.parseInt(prize.value.trim()); @@ -237,7 +241,6 @@ public class WheelManager { Item base = Emulator.getGameEnvironment().getItemManager().getItem(baseId); if (base == null) return; - int quantity = Math.max(1, prize.amount); THashSet items = new THashSet<>(); for (int i = 0; i < quantity; i++) { HabboItem item = Emulator.getGameEnvironment().getItemManager().createItem(habbo.getHabboInfo().getId(), base, 0, 0, ""); @@ -250,6 +253,16 @@ public class WheelManager { } private void recordWin(Habbo habbo, WheelPrize prize) { + WheelRecentWin win = new WheelRecentWin( + habbo.getHabboInfo().getUsername(), + habbo.getHabboInfo().getLook(), + prize.label); + + this.recentWinsCache.add(0, win); + while (this.recentWinsCache.size() > RECENT_KEEP) { + this.recentWinsCache.remove(this.recentWinsCache.size() - 1); + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { try (PreparedStatement statement = connection.prepareStatement( "INSERT INTO wheel_recent_wins (user_id, username, look, prize_label, won_at) VALUES (?, ?, ?, ?, ?)")) { @@ -261,7 +274,6 @@ public class WheelManager { statement.executeUpdate(); } - // Trim to the most recent RECENT_KEEP rows. try (PreparedStatement trim = connection.prepareStatement( "DELETE FROM wheel_recent_wins WHERE id < (SELECT id FROM (SELECT id FROM wheel_recent_wins ORDER BY id DESC LIMIT 1 OFFSET ?) t)")) { trim.setInt(1, RECENT_KEEP - 1); @@ -273,25 +285,38 @@ public class WheelManager { } public List getRecentWins(int limit) { - List wins = new ArrayList<>(); + if (limit <= 0) return new ArrayList<>(); + int size = this.recentWinsCache.size(); + if (size == 0) return new ArrayList<>(); + int take = Math.min(limit, size); + return new ArrayList<>(this.recentWinsCache.subList(0, take)); + } + + private void loadRecentWins() { + this.recentWinsCache.clear(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT username, look, prize_label FROM wheel_recent_wins ORDER BY id DESC LIMIT ?")) { - statement.setInt(1, limit); + statement.setInt(1, RECENT_KEEP); try (ResultSet set = statement.executeQuery()) { while (set.next()) { - wins.add(new WheelRecentWin(set.getString("username"), set.getString("look"), set.getString("prize_label"))); + this.recentWinsCache.add(new WheelRecentWin( + set.getString("username"), + set.getString("look"), + set.getString("prize_label"))); } } } catch (SQLException e) { LOGGER.error("Failed to load wheel recent wins", e); } - return wins; } - // Buys one extra spin with the configured currency. Returns false if the user can't afford it. public synchronized boolean buySpin(Habbo habbo) { if (this.spinCost <= 0) return false; + int userId = habbo.getHabboInfo().getId(); + WheelUserState state = this.getUserState(userId); + if (state.extraSpins >= MAX_EXTRA_SPINS) return false; + if (this.spinCostType == -1) { if (habbo.getHabboInfo().getCredits() < this.spinCost) return false; habbo.giveCredits(-this.spinCost); @@ -300,28 +325,42 @@ public class WheelManager { habbo.givePoints(this.spinCostType, -this.spinCost); } - int userId = habbo.getHabboInfo().getId(); - WheelUserState state = this.getUserState(userId); state.extraSpins++; this.persistUserState(userId, state); return true; } - // Admin: update one prize row. Caller reloads once after a batch. public void savePrize(int id, String type, String value, int amount, int pointsType, int weight, String label) { + String safeType = (type != null && VALID_PRIZE_TYPES.contains(type)) ? type : "nothing"; + String safeValue = truncate(value, MAX_STRING_LEN); + String safeLabel = truncate(label, MAX_STRING_LEN); + int safeAmount = clamp(amount, 0, MAX_PRIZE_AMOUNT); + int safeWeight = clamp(weight, 0, MAX_WEIGHT); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "UPDATE wheel_prizes SET type = ?, value = ?, amount = ?, points_type = ?, weight = ?, label = ? WHERE id = ?")) { - statement.setString(1, type != null ? type : "nothing"); - statement.setString(2, value != null ? value : ""); - statement.setInt(3, amount); + statement.setString(1, safeType); + statement.setString(2, safeValue); + statement.setInt(3, safeAmount); statement.setInt(4, pointsType); - statement.setInt(5, Math.max(0, weight)); - statement.setString(6, label != null ? label : ""); + statement.setInt(5, safeWeight); + statement.setString(6, safeLabel); statement.setInt(7, id); statement.executeUpdate(); } catch (SQLException e) { LOGGER.error("Failed to save wheel prize {}", id, e); } } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max); + } + + private static int clamp(int value, int min, int max) { + if (value < min) return min; + if (value > max) return max; + return value; + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java index a8b4e23a..e6aee93c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminGetPrizesEvent.java @@ -1,11 +1,12 @@ package com.eu.habbo.messages.incoming.wheel; 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.wheel.WheelAdminPrizesComposer; public class WheelAdminGetPrizesEvent extends MessageHandler { + public static final String PERMISSION_KEY = "acc_wheeladmin"; + @Override public int getRatelimit() { return 500; @@ -13,7 +14,7 @@ public class WheelAdminGetPrizesEvent extends MessageHandler { @Override public void handle() throws Exception { - if (this.client.getHabbo() == null || !this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { + if (this.client.getHabbo() == null || !this.client.getHabbo().hasPermission(PERMISSION_KEY)) { return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java index d63ab6cb..3dbf9235 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wheel/WheelAdminSavePrizesEvent.java @@ -1,13 +1,14 @@ package com.eu.habbo.messages.incoming.wheel; import com.eu.habbo.Emulator; -import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.wheel.WheelManager; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.wheel.WheelAdminPrizesComposer; import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer; public class WheelAdminSavePrizesEvent extends MessageHandler { + public static final String PERMISSION_KEY = "acc_wheeladmin"; + @Override public int getRatelimit() { return 1000; @@ -15,13 +16,15 @@ public class WheelAdminSavePrizesEvent extends MessageHandler { @Override public void handle() throws Exception { - if (this.client.getHabbo() == null || !this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { + if (this.client.getHabbo() == null || !this.client.getHabbo().hasPermission(PERMISSION_KEY)) { return; } WheelManager wheel = Emulator.getGameEnvironment().getWheelManager(); int count = this.packet.readInt(); + if (count <= 0 || count > WheelManager.MAX_PRIZES_PER_SAVE) return; + for (int i = 0; i < count; i++) { int id = this.packet.readInt(); String type = this.packet.readString(); @@ -30,13 +33,11 @@ public class WheelAdminSavePrizesEvent extends MessageHandler { int pointsType = this.packet.readInt(); int weight = this.packet.readInt(); String label = this.packet.readString(); - wheel.savePrize(id, type, value, amount, pointsType, weight, label); } wheel.reload(); - // Send the refreshed admin list + the player view so the editor updates live. this.client.sendResponse(new WheelAdminPrizesComposer(wheel.getPrizes())); this.client.sendResponse(new WheelDataComposer( wheel.getUserState(this.client.getHabbo().getHabboInfo().getId()),