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()),