From 969f177108c2bf2558b4ec43ca4fd8eba700cf91 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 07:02:08 +0000
Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?13=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index cb774ab9..5a5c1992 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.12
+ 4.2.13
UTF-8
From 4820ab15f30fb75d74167b62e8a51e26db8b85b5 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 12:03:07 +0000
Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?14=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 5a5c1992..81239a3e 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.13
+ 4.2.14
UTF-8
From b77290f5e788d0ee1a4624c277f927f7223b913c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 15:03:23 +0000
Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?15=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 81239a3e..028c4662 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.14
+ 4.2.15
UTF-8
From 67503aeb2afac48c39667aeb24a5925a144386a6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 22 May 2026 09:04:30 +0000
Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?16=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 028c4662..045e768e 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.15
+ 4.2.16
UTF-8
From 83d418e7127aa805cdd2282e11c991f40823db5e Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 26 May 2026 08:04:04 +0000
Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?17=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 045e768e..e44d38e6 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.16
+ 4.2.17
UTF-8
From 9328f4a3551b42af80c7653ab3d4e9daf56075f3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 26 May 2026 14:37:17 +0000
Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?18=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index e44d38e6..6164484d 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.17
+ 4.2.18
UTF-8
From ccadb81970c60c296ecce823439e186a6c3ce6c0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 26 May 2026 15:16:04 +0000
Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?19=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 6164484d..489b1819 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.18
+ 4.2.19
UTF-8
From 691dc426271d39daacaa0bdd745e5eb41e486f12 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 27 May 2026 07:43:14 +0000
Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?20=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 489b1819..934ef93a 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.19
+ 4.2.20
UTF-8
From 55b38e7b85e7a8e74dfc5477373a7a23f7163e8b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 27 May 2026 13:39:01 +0000
Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?21=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 934ef93a..76939bf5 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.20
+ 4.2.21
UTF-8
From 458b37dbed0901696420a6741d4c748684ab0e08 Mon Sep 17 00:00:00 2001
From: medievalshell
Date: Thu, 28 May 2026 02:39:01 +0200
Subject: [PATCH 10/15] 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 11/15] 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 e772686c4b017eeacb34fe3293f37a6f00a4064a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 28 May 2026 09:05:33 +0000
Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?22=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 76939bf5..f9310b9a 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.21
+ 4.2.22
UTF-8
From 1d6e05ee5717b269c134ac9b42982f3973ac5ba5 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 28 May 2026 09:35:48 +0000
Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?23=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index f9310b9a..6a83ea3b 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.22
+ 4.2.23
UTF-8
From 014ca9ca4819aab02c4af16fee72c1a14e4209e0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 28 May 2026 09:50:45 +0000
Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.?=
=?UTF-8?q?24=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Emulator/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 6a83ea3b..1f4ed186 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.23
+ 4.2.24
UTF-8
From e626a7fc50494db7265f05e13ef3dd26ee35a82b Mon Sep 17 00:00:00 2001
From: medievalshell
Date: Thu, 28 May 2026 12:33:50 +0200
Subject: [PATCH 15/15] 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" +