You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb4b9fb7f4 | |||
| 84d7968b76 | |||
| f5bf4baa79 | |||
| 4a02d22061 | |||
| 14854efaeb | |||
| 564c8d647e | |||
| 0e7138a721 | |||
| 76eb1ecd05 | |||
| 4621ed62b7 | |||
| 2b8ce3cd91 | |||
| 57c36da795 | |||
| 17629c210c | |||
| 50444003bb | |||
| f55b182d8e | |||
| 1416cd7464 | |||
| 392d24b9c5 | |||
| 9dcd58d027 | |||
| 3b85d5fa34 | |||
| 43c2c2b0f1 | |||
| a815c1b99d | |||
| caf6ad35fa | |||
| 258a95a269 | |||
| 4944d41410 | |||
| 8fb117ae73 | |||
| 7f4f7d6da9 | |||
| 0cf46471f2 | |||
| 3a505cd559 | |||
| f2e0f6e2d5 | |||
| d73573e7c5 | |||
| efb88e5957 | |||
| e7e75a285b | |||
| 28c3e93945 | |||
| 5bf1d42cfb | |||
| b162b3f4d8 | |||
| 86498b6b4c | |||
| 964f388594 | |||
| f9644d83b7 | |||
| 0b142d184c | |||
| 867c8ff857 | |||
| 5094d6ce4f | |||
| 2c0ef9873c | |||
| dadc1b8aaf | |||
| 85758b53fa | |||
| 2171b5f2ec | |||
| 46306c8205 | |||
| fadec887cd | |||
| e614c1d64f | |||
| e7deea7d9d | |||
| 44ea3abd4e | |||
| 609cd20ab2 | |||
| 717a7f184f | |||
| 2862446686 | |||
| e97e680006 | |||
| 7e59dca192 | |||
| 1109d53720 | |||
| f12363a5b7 | |||
| 7d4ffec74e | |||
| 281fede58c | |||
| edf152485b | |||
| 18a1bfbe90 | |||
| 7c32bbfd2d | |||
| 4eae206b64 | |||
| 155b2202c7 | |||
| 10c291eb9f | |||
| 349a8c727e | |||
| 68f2b71d14 | |||
| 69a6c0d060 | |||
| 9f36d95dbc | |||
| 885bdca0c4 | |||
| db035294a7 | |||
| 3216ba1df6 | |||
| c9a47b1fac | |||
| 7624d3fbc3 | |||
| e9129576a9 |
@@ -322,13 +322,6 @@ CREATE TABLE IF NOT EXISTS `custom_prefix_settings` (
|
||||
PRIMARY KEY (`key_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `custom_prefix_blacklist` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`word` VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_custom_prefix_blacklist_word` (`word`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO `custom_prefix_settings` (`key_name`, `value`) VALUES
|
||||
('max_length', '15'),
|
||||
('min_rank_to_buy', '1'),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
CREATE TABLE IF NOT EXISTS `habbo_mentions` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`target_user_id` INT(11) NOT NULL,
|
||||
`sender_user_id` INT(11) NOT NULL,
|
||||
`sender_username` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`room_id` INT(11) NOT NULL DEFAULT 0,
|
||||
`room_name` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`message` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`mention_type` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0 = direct (@nick), 1 = broadcast (@all/@friends/@room)',
|
||||
`timestamp` INT(11) NOT NULL DEFAULT 0,
|
||||
`read` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_target_id` (`target_user_id`, `id`),
|
||||
KEY `idx_target_unread` (`target_user_id`, `read`),
|
||||
KEY `idx_target_timestamp` (`target_user_id`, `timestamp`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
|
||||
|
||||
INSERT INTO `permission_definitions`
|
||||
(`permission_key`, `max_value`, `comment`,
|
||||
`rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`, `rank_8`)
|
||||
VALUES
|
||||
('acc_mention_everyone', 1,
|
||||
'Allow sending @all / @everyone / @tutti broadcast mentions (hotel-wide).',
|
||||
0, 0, 0, 0, 1, 1, 1, 1),
|
||||
('acc_mention_friends', 1,
|
||||
'Allow sending @friends / @amici broadcast mentions (sender''s online buddies).',
|
||||
0, 0, 0, 0, 1, 1, 1, 1),
|
||||
('cmd_disablementions', 1,
|
||||
'Allow toggling :disablementions to stop receiving any @mention notifications.',
|
||||
1, 1, 1, 1, 1, 1, 1, 1),
|
||||
('cmd_disablemassmentions', 1,
|
||||
'Allow toggling :disablemassmentions to stop receiving broadcast mentions (direct @nick still works).',
|
||||
1, 1, 1, 1, 1, 1, 1, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`comment` = VALUES(`comment`);
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. Emulator settings: cooldowns, caps and alias lists
|
||||
--
|
||||
-- Only inserted when missing - existing tuned values are preserved.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
|
||||
('mentions.enabled', '1',
|
||||
'Master switch. 1 = process @mentions, 0 = disable the feature entirely.'),
|
||||
('mentions.max.targets', '50',
|
||||
'Hard cap on how many users a single broadcast (@all / @friends / @room) can fan out to.'),
|
||||
('mentions.cooldown.ms', '3000',
|
||||
'Per-sender cooldown between any two mentions, in milliseconds.'),
|
||||
('mentions.room.cooldown.ms', '15000',
|
||||
'Extra per-sender cooldown for broadcast mentions (@all / @friends / @room) on top of mentions.cooldown.ms.'),
|
||||
('mentions.store.limit', '50',
|
||||
'Number of mentions returned in the initial RequestMentionsList response.'),
|
||||
('mentions.request.cooldown.ms', '2000',
|
||||
'Per-user cooldown between RequestMentionsList packets.'),
|
||||
('mentions.markread.cooldown.ms', '500',
|
||||
'Per-user cooldown between mark-single-as-read packets.'),
|
||||
('mentions.markall.cooldown.ms', '5000',
|
||||
'Per-user cooldown between mark-all-as-read packets (bulk DB update).'),
|
||||
('mentions.delete.cooldown.ms', '500',
|
||||
'Per-user cooldown between delete-mention packets.'),
|
||||
('mentions.everyone.aliases', 'all,everyone,tutti',
|
||||
'Comma-separated aliases that trigger an @everyone broadcast (requires acc_mention_everyone).'),
|
||||
('mentions.friends.aliases', 'friends,amici',
|
||||
'Comma-separated aliases that trigger an @friends broadcast (requires acc_mention_friends).'),
|
||||
('mentions.room.aliases', 'room,stanza',
|
||||
'Comma-separated aliases that trigger an @room broadcast (no permission required, room scope only).');
|
||||
|
||||
|
||||
ALTER TABLE `wordfilter`
|
||||
ADD COLUMN `prefix_only` ENUM('0','1') NOT NULL DEFAULT '0'
|
||||
COMMENT 'When 1, this word only applies to custom prefixes, not to chat/motto/guild.' AFTER `mute`;
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 5. Per-user mention preferences (:disablementions / :disablemassmentions)
|
||||
--
|
||||
-- Read by HabboStats (default '1' = enabled), toggled by the commands.
|
||||
-- Without these columns the toggle commands cannot persist.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE `users_settings`
|
||||
ADD COLUMN IF NOT EXISTS `mentions_enabled` ENUM('0','1') NOT NULL DEFAULT '1'
|
||||
COMMENT 'Receive @nick mention notifications.',
|
||||
ADD COLUMN IF NOT EXISTS `mass_mentions_enabled` ENUM('0','1') NOT NULL DEFAULT '1'
|
||||
COMMENT 'Receive broadcast (@all / @friends / @room) mentions.';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 020_furnidata_edit_log.sql
|
||||
-- Audit trail for furnidata name/description edits made through the furni editor,
|
||||
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
|
||||
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
|
||||
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`classname` varchar(255) NOT NULL,
|
||||
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
|
||||
`old_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`old_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`timestamp` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_classname` (`classname`),
|
||||
INDEX `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||
('items.furnidata.edit.backup.keep','10'),
|
||||
('items.furnidata.edit.ratelimit.ms','2000');
|
||||
@@ -63,15 +63,6 @@ CREATE TABLE IF NOT EXISTS `custom_prefix_settings` (
|
||||
PRIMARY KEY (`key_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 5. Blacklist table
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `custom_prefix_blacklist` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`word` VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_word` (`word`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Schema upgrades for existing installations
|
||||
@@ -296,14 +287,6 @@ INSERT IGNORE INTO `custom_prefixes_catalog`
|
||||
(2, 'Legend', 'Legend', '#8B5CF6', '', 'discord-neon', '', 15, 0, 1, 2),
|
||||
(3, 'Staff Pick', 'Staff', '#3B82F6', '*', 'cartoon', '', 20, 0, 1, 3);
|
||||
|
||||
-- ============================================================
|
||||
-- Example blacklist entries
|
||||
-- ============================================================
|
||||
INSERT IGNORE INTO `custom_prefix_blacklist` (`word`) VALUES
|
||||
('admin'),
|
||||
('staff'),
|
||||
('mod'),
|
||||
('owner');
|
||||
|
||||
-- ============================================================
|
||||
-- Notes
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 020_furnidata_edit_log.sql
|
||||
-- Audit trail for furnidata name/description edits made through the furni editor,
|
||||
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
|
||||
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
|
||||
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`classname` varchar(255) NOT NULL,
|
||||
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
|
||||
`old_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_name` varchar(256) NOT NULL DEFAULT '',
|
||||
`old_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`new_description` varchar(256) NOT NULL DEFAULT '',
|
||||
`timestamp` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_classname` (`classname`),
|
||||
INDEX `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||
('items.furnidata.edit.backup.keep','10'),
|
||||
('items.furnidata.edit.ratelimit.ms','2000');
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 021_furnidata_config.sql
|
||||
-- Seeds the furnidata feature config keys read at runtime by
|
||||
-- FurnitureTextProvider / FurnidataReader / FurnidataWatcher and
|
||||
-- FurniEditorImportTextEvent. Without these rows a fresh install logs
|
||||
-- "Config key not found" for each (ConfigurationManager logs ERROR even
|
||||
-- when a default is supplied) and the values are not editable from the DB.
|
||||
--
|
||||
-- Notes:
|
||||
-- * *.enabled keys are read via Boolean.parseBoolean → use true/false (NOT 1/0).
|
||||
-- * items.furnidata.path is intentionally empty: when blank the source is
|
||||
-- derived from furni.editor.asset.base.path (seeded by 004_furni_editor.sql)
|
||||
-- → <base>/furnidata (split-tier) or <base>/FurnitureData.json (single file).
|
||||
-- * Editor write-path keys (items.furnidata.edit.*) are seeded by 020.
|
||||
|
||||
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||
-- Server-authoritative furni names (source of truth = furnidata JSON)
|
||||
('items.furnidata.names.enabled','true'),
|
||||
('items.furnidata.path',''),
|
||||
('items.furnidata.max.bytes','67108864'),
|
||||
-- Live-reload watcher
|
||||
('items.furnidata.watch.enabled','true'),
|
||||
('items.furnidata.watch.debounce.ms','750'),
|
||||
('items.furnidata.watch.min.interval.ms','5000'),
|
||||
('items.furnidata.delta.cap','500'),
|
||||
-- Furni editor: import official names/descriptions from Habbo
|
||||
('furni.editor.import.url','https://www.habbo.it/gamedata/furnidata_json/1'),
|
||||
('furni.editor.import.cache.ms','600000');
|
||||
+16
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.eu.habbo</groupId>
|
||||
<artifactId>Habbo</artifactId>
|
||||
<version>4.2.28</version>
|
||||
<version>4.2.38</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -62,6 +62,12 @@
|
||||
<show>public</show>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
@@ -172,12 +178,20 @@
|
||||
<version>0.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Jakarta Mail � used by the built-in forgot-password endpoint
|
||||
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
|
||||
when smtp.* keys are configured in emulator_settings -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.angus</groupId>
|
||||
<artifactId>jakarta.mail</artifactId>
|
||||
<version>2.0.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JUnit Jupiter -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -8,11 +8,13 @@ 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.mentions.MentionManager;
|
||||
import com.eu.habbo.habbohotel.commands.CommandHandler;
|
||||
import com.eu.habbo.habbohotel.crafting.CraftingManager;
|
||||
import com.eu.habbo.habbohotel.guides.GuideManager;
|
||||
import com.eu.habbo.habbohotel.guilds.GuildManager;
|
||||
import com.eu.habbo.habbohotel.hotelview.HotelViewManager;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||
import com.eu.habbo.habbohotel.items.ItemManager;
|
||||
import com.eu.habbo.habbohotel.modtool.ModToolManager;
|
||||
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
|
||||
@@ -46,6 +48,7 @@ public class GameEnvironment {
|
||||
private NavigatorManager navigatorManager;
|
||||
private GuildManager guildManager;
|
||||
private ItemManager itemManager;
|
||||
private FurnitureTextProvider furnitureTextProvider;
|
||||
private CatalogManager catalogManager;
|
||||
private HotelViewManager hotelViewManager;
|
||||
private RoomManager roomManager;
|
||||
@@ -68,6 +71,7 @@ public class GameEnvironment {
|
||||
private InfostandBackgroundManager infostandBackgroundManager;
|
||||
private WheelManager wheelManager;
|
||||
private SoundboardManager soundboardManager;
|
||||
private MentionManager mentionManager;
|
||||
|
||||
public void load() throws Exception {
|
||||
LOGGER.info("GameEnvironment -> Loading...");
|
||||
@@ -77,6 +81,8 @@ public class GameEnvironment {
|
||||
this.hotelViewManager = new HotelViewManager();
|
||||
this.itemManager = new ItemManager();
|
||||
this.itemManager.load();
|
||||
this.furnitureTextProvider = new FurnitureTextProvider();
|
||||
this.furnitureTextProvider.init();
|
||||
this.botManager = new BotManager();
|
||||
this.petManager = new PetManager();
|
||||
this.guildManager = new GuildManager();
|
||||
@@ -99,6 +105,7 @@ public class GameEnvironment {
|
||||
this.infostandBackgroundManager = new InfostandBackgroundManager();
|
||||
this.wheelManager = new WheelManager();
|
||||
this.soundboardManager = new SoundboardManager();
|
||||
this.mentionManager = new MentionManager();
|
||||
|
||||
this.roomManager.loadPublicRooms();
|
||||
this.navigatorManager.loadNavigator();
|
||||
@@ -158,6 +165,10 @@ public class GameEnvironment {
|
||||
return this.itemManager;
|
||||
}
|
||||
|
||||
public FurnitureTextProvider getFurnitureTextProvider() {
|
||||
return this.furnitureTextProvider;
|
||||
}
|
||||
|
||||
public CatalogManager getCatalogManager() {
|
||||
return this.catalogManager;
|
||||
}
|
||||
@@ -202,6 +213,10 @@ public class GameEnvironment {
|
||||
return this.petManager;
|
||||
}
|
||||
|
||||
public MentionManager getMentionManager() {
|
||||
return this.mentionManager;
|
||||
}
|
||||
|
||||
public AchievementManager getAchievementManager() {
|
||||
return this.achievementManager;
|
||||
}
|
||||
|
||||
@@ -1054,13 +1054,13 @@ public class CatalogManager {
|
||||
if (Emulator.getConfig().getBoolean("hotel.catalog.ltd.limit.enabled")) {
|
||||
int ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.total");
|
||||
if (habbo.getHabboStats().totalLtds() >= ltdLimit) {
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + ""));
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + ""));
|
||||
return;
|
||||
}
|
||||
|
||||
ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.item");
|
||||
if (habbo.getHabboStats().totalLtds(item.id) >= ltdLimit) {
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + ""));
|
||||
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + ""));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,8 @@ public class CommandHandler {
|
||||
addCommand(new CreditsCommand());
|
||||
addCommand(new DanceCommand());
|
||||
addCommand(new DiagonalCommand());
|
||||
addCommand(new DisableMassMentionsCommand());
|
||||
addCommand(new DisableMentionsCommand());
|
||||
addCommand(new DisconnectCommand());
|
||||
addCommand(new EjectAllCommand());
|
||||
addCommand(new EmptyInventoryCommand());
|
||||
@@ -301,7 +303,6 @@ public class CommandHandler {
|
||||
addCommand(new GivePrefixCommand());
|
||||
addCommand(new ListPrefixesCommand());
|
||||
addCommand(new RemovePrefixCommand());
|
||||
addCommand(new PrefixBlacklistCommand());
|
||||
addCommand(new WiredCommand());
|
||||
addCommand(new TestCommand());
|
||||
}
|
||||
|
||||
@@ -17,7 +17,22 @@ public class CommandsCommand extends Command {
|
||||
message.append("(").append(commands.size()).append("):\r\n");
|
||||
|
||||
for (Command c : commands) {
|
||||
message.append(Emulator.getTexts().getValue("commands.description." + c.permission, "commands.description." + c.permission)).append("\r");
|
||||
String textKey = "commands.description." + c.permission;
|
||||
String commandText = Emulator.getTexts().getValue(textKey, "");
|
||||
String commandLine = ":" + c.keys[0];
|
||||
String description = "";
|
||||
|
||||
if (commandText.startsWith(":")) {
|
||||
commandLine = commandText;
|
||||
} else if (!commandText.isEmpty() && !commandText.equals(textKey)) {
|
||||
description = commandText;
|
||||
}
|
||||
|
||||
message.append(commandLine).append("\r");
|
||||
|
||||
if (!description.isEmpty()) {
|
||||
message.append(description).append("\r");
|
||||
}
|
||||
}
|
||||
|
||||
gameClient.getHabbo().alert(new String[]{message.toString()});
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.eu.habbo.habbohotel.commands;
|
||||
|
||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
|
||||
public class DisableMassMentionsCommand extends Command {
|
||||
public DisableMassMentionsCommand() {
|
||||
super("cmd_disablemassmentions", new String[]{"disablemassmentions", "togglemassmentions"});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handle(GameClient gameClient, String[] params) throws Exception {
|
||||
if (gameClient == null) return true;
|
||||
Habbo habbo = gameClient.getHabbo();
|
||||
if (habbo == null || habbo.getHabboStats() == null) return true;
|
||||
|
||||
boolean newState = !habbo.getHabboStats().massMentionsEnabled();
|
||||
habbo.getHabboStats().setMassMentionsEnabled(newState);
|
||||
|
||||
habbo.whisper(newState
|
||||
? "Broadcast mentions (@all / @friends / @room) are now ENABLED for you."
|
||||
: "Broadcast mentions (@all / @friends / @room) are now DISABLED for you. Direct @nick mentions still work.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eu.habbo.habbohotel.commands;
|
||||
|
||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
|
||||
public class DisableMentionsCommand extends Command {
|
||||
public DisableMentionsCommand() {
|
||||
super("cmd_disablementions", new String[]{"disablementions", "togglementions"});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handle(GameClient gameClient, String[] params) throws Exception {
|
||||
if (gameClient == null) return true;
|
||||
Habbo habbo = gameClient.getHabbo();
|
||||
if (habbo == null || habbo.getHabboStats() == null) return true;
|
||||
|
||||
boolean newState = !habbo.getHabboStats().mentionsEnabled();
|
||||
habbo.getHabboStats().setMentionsEnabled(newState);
|
||||
|
||||
habbo.whisper(newState
|
||||
? "@mention notifications are now ENABLED for you."
|
||||
: "@mention notifications are now DISABLED for you. You will not receive direct or broadcast mentions.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
||||
import com.eu.habbo.habbohotel.modtool.WordFilter;
|
||||
import com.eu.habbo.habbohotel.modtool.WordFilterWord;
|
||||
import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -21,30 +22,44 @@ public class FilterWordCommand extends Command {
|
||||
@Override
|
||||
public boolean handle(GameClient gameClient, String[] params) throws Exception {
|
||||
if (params.length < 2) {
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.missing_word"));
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.missing_word"), RoomChatMessageBubbles.ALERT);
|
||||
return true;
|
||||
}
|
||||
|
||||
String word = params[1];
|
||||
|
||||
// Optional trailing "prefix" keyword marks the word as prefix-only (blocks
|
||||
// custom prefixes but not chat/motto/guild). Usage:
|
||||
// :filterword <word> -> everywhere, default replacement
|
||||
// :filterword <word> <replacement> -> everywhere
|
||||
// :filterword <word> prefix -> prefix-only, default replacement
|
||||
// :filterword <word> <replacement> prefix -> prefix-only
|
||||
boolean prefixOnly = false;
|
||||
String replacement = WordFilter.DEFAULT_REPLACEMENT;
|
||||
if (params.length == 3) {
|
||||
replacement = params[2];
|
||||
|
||||
if (params.length >= 3) {
|
||||
if (params[params.length - 1].equalsIgnoreCase("prefix")) {
|
||||
prefixOnly = true;
|
||||
if (params.length >= 4) replacement = params[2];
|
||||
} else {
|
||||
replacement = params[2];
|
||||
}
|
||||
}
|
||||
|
||||
WordFilterWord wordFilterWord = new WordFilterWord(word, replacement);
|
||||
WordFilterWord wordFilterWord = new WordFilterWord(word, replacement, prefixOnly);
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO wordfilter (`key`, `replacement`) VALUES (?, ?)")) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO wordfilter (`key`, `replacement`, `prefix_only`) VALUES (?, ?, ?)")) {
|
||||
statement.setString(1, word);
|
||||
statement.setString(2, replacement);
|
||||
statement.setString(3, prefixOnly ? "1" : "0");
|
||||
statement.execute();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.error"));
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.error"), RoomChatMessageBubbles.ALERT);
|
||||
return true;
|
||||
}
|
||||
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_filterword.added").replace("%word%", word).replace("%replacement%", replacement));
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_filterword.added").replace("%word%", word).replace("%replacement%", replacement) + (prefixOnly ? " [prefix-only]" : ""), RoomChatMessageBubbles.ALERT);
|
||||
Emulator.getGameEnvironment().getWordFilter().addWord(wordFilterWord);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.eu.habbo.habbohotel.commands;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
||||
import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public class PrefixBlacklistCommand extends Command {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PrefixBlacklistCommand.class);
|
||||
|
||||
public PrefixBlacklistCommand() {
|
||||
super("cmd_prefix_blacklist", Emulator.getTexts().getValue("commands.keys.cmd_prefix_blacklist").split(";"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handle(GameClient gameClient, String[] params) throws Exception {
|
||||
if (params.length < 2) {
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.usage"), RoomChatMessageBubbles.ALERT);
|
||||
return true;
|
||||
}
|
||||
|
||||
String action = params[1].toLowerCase();
|
||||
|
||||
if (action.equals("list")) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.header")).append("\r");
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT word FROM custom_prefix_blacklist ORDER BY word")) {
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
int count = 0;
|
||||
while (set.next()) {
|
||||
sb.append("- ").append(set.getString("word")).append("\r");
|
||||
count++;
|
||||
}
|
||||
if (count == 0) {
|
||||
sb.append(Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.empty"));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error listing prefix blacklist", e);
|
||||
}
|
||||
|
||||
gameClient.getHabbo().whisper(sb.toString(), RoomChatMessageBubbles.ALERT);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (params.length < 3) {
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.usage"), RoomChatMessageBubbles.ALERT);
|
||||
return true;
|
||||
}
|
||||
|
||||
String word = params[2].toLowerCase().trim();
|
||||
|
||||
if (word.isEmpty()) {
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.empty_word"), RoomChatMessageBubbles.ALERT);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.equals("add")) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("INSERT INTO custom_prefix_blacklist (word) VALUES (?)")) {
|
||||
statement.setString(1, word);
|
||||
statement.execute();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error adding prefix blacklist word", e);
|
||||
}
|
||||
|
||||
gameClient.getHabbo().whisper(
|
||||
Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.added").replace("%word%", word),
|
||||
RoomChatMessageBubbles.ALERT
|
||||
);
|
||||
} else if (action.equals("remove")) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("DELETE FROM custom_prefix_blacklist WHERE word = ?")) {
|
||||
statement.setString(1, word);
|
||||
statement.execute();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error removing prefix blacklist word", e);
|
||||
}
|
||||
|
||||
gameClient.getHabbo().whisper(
|
||||
Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.removed").replace("%word%", word),
|
||||
RoomChatMessageBubbles.ALERT
|
||||
);
|
||||
} else {
|
||||
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.usage"), RoomChatMessageBubbles.ALERT);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,25 @@ public class Guild implements Runnable {
|
||||
return this.readForum;
|
||||
}
|
||||
|
||||
public boolean canHabboReadForum(int habboId, GuildMember member, boolean staff) {
|
||||
if (staff || this.getOwnerId() == habboId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (this.readForum) {
|
||||
case EVERYONE:
|
||||
return true;
|
||||
case MEMBERS:
|
||||
return member != null && member.getRank().type <= GuildRank.MEMBER.type;
|
||||
case ADMINS:
|
||||
return member != null && member.getRank().type < GuildRank.MEMBER.type;
|
||||
case OWNER:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void setReadForum(SettingsState readForum) {
|
||||
this.readForum = readForum;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
/**
|
||||
* One parsed furnidata entry. {@code classname} is the raw furnidata classname
|
||||
* (may carry a {@code *N} colour-variant suffix); the provider keys on the base.
|
||||
*/
|
||||
public record FurnidataEntry(int id, String classname, FurnitureType type, String name, String description) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* One process-wide lock serializing every furnidata reindex and every editor-driven
|
||||
* furnidata write, so an editor write never races the file watcher's reindex and the
|
||||
* volatile index is never observed mid-swap by two writers.
|
||||
*/
|
||||
public final class FurnidataLock {
|
||||
public static final ReentrantLock LOCK = new ReentrantLock();
|
||||
private FurnidataLock() {}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Neutral furnidata reader. Supports a single JSON/JSON5 file or a split-tier
|
||||
* directory ({@code core/custom/seasonal} with {@code manifest.json(5)}).
|
||||
* Never throws: any IO/parse error yields an empty list (the caller decides the
|
||||
* fallback). All resolved paths are guarded against escaping the base dir.
|
||||
*/
|
||||
public class FurnidataReader {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataReader.class);
|
||||
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
|
||||
|
||||
private final Path source;
|
||||
private final long maxBytes;
|
||||
|
||||
public FurnidataReader(Path source, long maxBytes) {
|
||||
this.source = source;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
public List<FurnidataEntry> read() {
|
||||
List<FurnidataEntry> out = new ArrayList<>();
|
||||
try {
|
||||
if (this.source == null || !Files.exists(this.source)) return out;
|
||||
|
||||
if (Files.isDirectory(this.source)) {
|
||||
readSplitDir(this.source, out);
|
||||
} else {
|
||||
String content = readJson5Capped(this.source);
|
||||
if (content != null) {
|
||||
parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataReader failed to read {} — returning empty", this.source, e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void readSplitDir(Path base, List<FurnidataEntry> out) {
|
||||
List<String> tiers = readManifestList(base, "tiers", DEFAULT_TIERS);
|
||||
Path baseNorm = base.toAbsolutePath().normalize();
|
||||
|
||||
for (String tier : tiers) {
|
||||
Path tierDir = base.resolve(tier);
|
||||
if (!isInside(baseNorm, tierDir) || !Files.isDirectory(tierDir)) continue;
|
||||
|
||||
for (String fileName : readManifestList(tierDir, "files", List.of())) {
|
||||
Path file = tierDir.resolve(fileName);
|
||||
if (!isInside(baseNorm, file)) {
|
||||
LOGGER.warn("FurnidataReader: ignoring out-of-base file {}", file);
|
||||
continue;
|
||||
}
|
||||
if (!Files.exists(file)) continue;
|
||||
try {
|
||||
String content = readJson5Capped(file);
|
||||
if (content != null) parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataReader: failed to parse {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readManifestList(Path dir, String key, List<String> fallback) {
|
||||
for (String name : MANIFEST_NAMES) {
|
||||
Path m = dir.resolve(name);
|
||||
if (!Files.exists(m)) continue;
|
||||
try {
|
||||
String raw = readJson5Capped(m);
|
||||
if (raw == null) continue;
|
||||
JsonObject obj = JsonParser.parseString(raw).getAsJsonObject();
|
||||
if (obj.has(key) && obj.get(key).isJsonArray()) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString());
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataReader: bad manifest {}", m, e);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private void parseRoot(JsonObject root, List<FurnidataEntry> out) {
|
||||
for (String section : SECTIONS) {
|
||||
if (!root.has(section)) continue;
|
||||
JsonObject sectionObj = root.getAsJsonObject(section);
|
||||
if (!sectionObj.has("furnitype")) continue;
|
||||
FurnitureType type = section.equals("roomitemtypes") ? FurnitureType.FLOOR : FurnitureType.WALL;
|
||||
JsonArray types = sectionObj.getAsJsonArray("furnitype");
|
||||
for (JsonElement el : types) {
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
if (!o.has("id") || !o.has("classname")) continue;
|
||||
out.add(new FurnidataEntry(
|
||||
o.get("id").getAsInt(),
|
||||
o.get("classname").getAsString(),
|
||||
type,
|
||||
o.has("name") ? o.get("name").getAsString() : "",
|
||||
o.has("description") ? o.get("description").getAsString() : ""
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the JSON5-stripped content, or null if the file exceeds the byte cap. */
|
||||
private String readJson5Capped(Path path) throws Exception {
|
||||
long size = Files.size(path);
|
||||
if (size > this.maxBytes) {
|
||||
LOGGER.warn("FurnidataReader: {} is {} bytes, over cap {} — refusing", path, size, this.maxBytes);
|
||||
return null;
|
||||
}
|
||||
return stripJson5(Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static boolean isInside(Path baseNorm, Path candidate) {
|
||||
return candidate.toAbsolutePath().normalize().startsWith(baseNorm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip // and block comments and trailing commas so Gson can parse JSON5.
|
||||
* Known limitation: the trailing-comma pass is a regex over the whole output,
|
||||
* so a string value literally containing ",[whitespace]}" or ",[whitespace]]"
|
||||
* would be altered. Real Habbo furnidata names/descriptions do not contain
|
||||
* that pattern; values are additionally sanitized downstream before use.
|
||||
*/
|
||||
static String stripJson5(String content) {
|
||||
if (content == null || content.isEmpty()) return content;
|
||||
StringBuilder out = new StringBuilder(content.length());
|
||||
int i = 0, len = content.length();
|
||||
boolean inString = false, escape = false;
|
||||
char stringChar = 0;
|
||||
while (i < len) {
|
||||
char c = content.charAt(i);
|
||||
if (inString) {
|
||||
out.append(c);
|
||||
if (escape) escape = false;
|
||||
else if (c == '\\') escape = true;
|
||||
else if (c == stringChar) inString = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"' || c == '\'') { inString = true; stringChar = c; out.append(c); i++; continue; }
|
||||
if (c == '/' && i + 1 < len) {
|
||||
char next = content.charAt(i + 1);
|
||||
if (next == '/') { int eol = content.indexOf('\n', i + 2); if (eol < 0) break; i = eol; continue; }
|
||||
if (next == '*') { int end = content.indexOf("*/", i + 2); if (end < 0) break; i = end + 2; continue; }
|
||||
}
|
||||
out.append(c);
|
||||
i++;
|
||||
}
|
||||
return out.toString().replaceAll(",(\\s*[}\\]])", "$1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.ClosedWatchServiceException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardWatchEventKinds;
|
||||
import java.nio.file.WatchKey;
|
||||
import java.nio.file.WatchService;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Watches the furnidata source on a single daemon thread. On change (debounced),
|
||||
* re-indexes via the provider and broadcasts only the delta — or a compact
|
||||
* reload-hint when the delta exceeds the cap. A minimum interval throttles bursts.
|
||||
* For the split-tier directory layout, the base dir AND its immediate
|
||||
* subdirectories are registered. Never throws out of the loop.
|
||||
*/
|
||||
public class FurnidataWatcher {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataWatcher.class);
|
||||
|
||||
private final FurnitureTextProvider provider;
|
||||
private final Path watchDir;
|
||||
private final boolean sourceIsDir;
|
||||
private final long maxBytes;
|
||||
private final long debounceMs;
|
||||
private final long minIntervalMs;
|
||||
private final int deltaCap;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private volatile WatchService ws;
|
||||
private long lastBroadcast = 0L;
|
||||
|
||||
public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) {
|
||||
this.provider = provider;
|
||||
this.sourceIsDir = Files.isDirectory(source);
|
||||
this.watchDir = this.sourceIsDir ? source : source.getParent();
|
||||
this.maxBytes = maxBytes;
|
||||
this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750"));
|
||||
this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000"));
|
||||
this.deltaCap = Integer.parseInt(Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (this.running || this.watchDir == null) return;
|
||||
this.running = true;
|
||||
Thread t = new Thread(this::run, "FurnidataWatcher");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
this.running = false;
|
||||
WatchService local = this.ws;
|
||||
if (local != null) {
|
||||
try { local.close(); } catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try {
|
||||
this.ws = FileSystems.getDefault().newWatchService();
|
||||
} catch (IOException e) {
|
||||
LOGGER.warn("FurnidataWatcher: could not create WatchService", e);
|
||||
return;
|
||||
}
|
||||
try (WatchService service = this.ws) {
|
||||
registerDirs(service);
|
||||
while (this.running) {
|
||||
WatchKey key = service.take();
|
||||
key.pollEvents();
|
||||
Thread.sleep(this.debounceMs);
|
||||
key.pollEvents();
|
||||
if (!key.reset()) {
|
||||
LOGGER.warn("FurnidataWatcher: watch key invalidated (directory removed?) — stopping");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
onChange();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataWatcher: onChange failed", e);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ClosedWatchServiceException ignored) {
|
||||
// stop() closed the service — normal shutdown
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataWatcher stopped", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the base dir, plus one level of subdirectories for the split-tier layout. */
|
||||
private void registerDirs(WatchService service) throws IOException {
|
||||
this.watchDir.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||
if (this.sourceIsDir) {
|
||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(this.watchDir)) {
|
||||
for (Path child : ds) {
|
||||
if (Files.isDirectory(child)) {
|
||||
child.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onChange() {
|
||||
FurnidataLock.LOCK.lock();
|
||||
try {
|
||||
Path source = this.provider.getSource();
|
||||
if (source == null) return;
|
||||
|
||||
List<FurnidataEntry> delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
|
||||
if (delta.isEmpty()) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - this.lastBroadcast < this.minIntervalMs) {
|
||||
LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size());
|
||||
return;
|
||||
}
|
||||
this.lastBroadcast = now;
|
||||
|
||||
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
|
||||
broadcast(composer);
|
||||
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
|
||||
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
|
||||
} finally {
|
||||
FurnidataLock.LOCK.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcast(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Comment-preserving, atomic, backed-up writer for furnidata name/description, keyed by
|
||||
* classname. Supports single-file and split-tier (writes the tier that currently resolves
|
||||
* the classname). Edit-only: refuses classnames absent from the furnidata.
|
||||
*/
|
||||
public class FurnidataWriter {
|
||||
|
||||
/** Default tier names in override order (later = higher priority, wins on conflict). */
|
||||
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||
|
||||
/** Manifest filenames tried in order (json5 first, plain json second). */
|
||||
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||
|
||||
private final Path source; // file (single) or base dir (split-tier)
|
||||
private final boolean directory; // true => split-tier
|
||||
private final long maxBytes;
|
||||
private final int backupKeep;
|
||||
|
||||
public FurnidataWriter(Path source, boolean directory, long maxBytes, int backupKeep) {
|
||||
this.source = source;
|
||||
this.directory = directory;
|
||||
this.maxBytes = maxBytes;
|
||||
this.backupKeep = Math.max(1, backupKeep);
|
||||
}
|
||||
|
||||
/** @return true if an entry for classname was found and written. */
|
||||
public boolean write(String classname, String name, String description) throws IOException {
|
||||
String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (cn.isEmpty()) return false;
|
||||
String safeName = FurnitureTextProvider.sanitize(name);
|
||||
String safeDesc = FurnitureTextProvider.sanitize(description);
|
||||
|
||||
Path target = locateFile(cn);
|
||||
if (target == null) return false;
|
||||
|
||||
String raw = Files.readString(target, StandardCharsets.UTF_8);
|
||||
String edited = replaceEntryFields(raw, cn, safeName, safeDesc);
|
||||
if (edited == null || edited.equals(raw)) {
|
||||
// classname not present in this file, or no change
|
||||
return edited != null && !edited.equals(raw);
|
||||
}
|
||||
backup(target);
|
||||
atomicWrite(target, edited);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** For single-file just returns the file; for split-tier, the tier file that contains cn. */
|
||||
private Path locateFile(String cn) throws IOException {
|
||||
if (!directory) {
|
||||
// confirm existence via the reader (size-guarded, parses the same way)
|
||||
return containsClassname(source, cn) ? source : null;
|
||||
}
|
||||
// split-tier: iterate tiers in OVERRIDE order (later tiers win); pick the last containing cn
|
||||
Path winner = null;
|
||||
for (Path tierFile : splitTierFilesInOrder()) {
|
||||
if (containsClassname(tierFile, cn)) winner = tierFile;
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
private boolean containsClassname(Path file, String cn) {
|
||||
for (FurnidataEntry e : new FurnidataReader(file, maxBytes).read()) {
|
||||
if (e.classname() != null && e.classname().trim().toLowerCase(java.util.Locale.ROOT).equals(cn)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the "name" and "description" string values inside the JSON object that holds
|
||||
* "classname": "<cn>". Preserves everything else (comments, ordering, formatting).
|
||||
* Handles double- and single-quoted JSON5 keys/values. Returns null if cn not found.
|
||||
*/
|
||||
static String replaceEntryFields(String raw, String cn, String name, String description) {
|
||||
// find the classname value occurrence (case-insensitive on the value)
|
||||
Pattern classProp = Pattern.compile(
|
||||
"([\"'])classname\\1\\s*:\\s*([\"'])((?:\\\\.|(?!\\2).)*)\\2", Pattern.CASE_INSENSITIVE);
|
||||
Matcher m = classProp.matcher(raw);
|
||||
int objStart = -1, objEnd = -1;
|
||||
while (m.find()) {
|
||||
String val = m.group(3).trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (!val.equals(cn)) continue;
|
||||
// expand to the enclosing { ... }
|
||||
objStart = lastUnbalancedBrace(raw, m.start());
|
||||
objEnd = matchingClose(raw, objStart);
|
||||
break;
|
||||
}
|
||||
if (objStart < 0 || objEnd < 0) return null;
|
||||
String obj = raw.substring(objStart, objEnd + 1);
|
||||
String newObj = replaceField(obj, "name", name);
|
||||
newObj = replaceField(newObj, "description", description);
|
||||
return raw.substring(0, objStart) + newObj + raw.substring(objEnd + 1);
|
||||
}
|
||||
|
||||
private static String replaceField(String obj, String field, String value) {
|
||||
Pattern p = Pattern.compile(
|
||||
"(([\"'])" + Pattern.quote(field) + "\\2\\s*:\\s*)([\"'])((?:\\\\.|(?!\\3).)*)\\3");
|
||||
Matcher m = p.matcher(obj);
|
||||
if (!m.find()) return obj; // field absent → leave object as-is
|
||||
String replacement = m.group(1) + '"' + jsonEscape(value) + '"';
|
||||
return obj.substring(0, m.start()) + replacement + obj.substring(m.end());
|
||||
}
|
||||
|
||||
private static int lastUnbalancedBrace(String s, int from) {
|
||||
int depth = 0;
|
||||
for (int i = from; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '}') depth++;
|
||||
else if (c == '{') { if (depth == 0) return i; depth--; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int matchingClose(String s, int open) {
|
||||
int depth = 0; boolean inStr = false; char q = 0;
|
||||
for (int i = open; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (inStr) { if (c == '\\') { i++; } else if (c == q) inStr = false; continue; }
|
||||
if (c == '"' || c == '\'') { inStr = true; q = c; }
|
||||
else if (c == '{') depth++;
|
||||
else if (c == '}') { depth--; if (depth == 0) return i; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static String jsonEscape(String v) {
|
||||
StringBuilder b = new StringBuilder(v.length() + 8);
|
||||
for (int i = 0; i < v.length(); i++) {
|
||||
char c = v.charAt(i);
|
||||
if (c == '"' || c == '\\') b.append('\\').append(c);
|
||||
else b.append(c);
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every data file reachable from the split-tier base directory, in
|
||||
* override order (core → custom → seasonal, or the order declared in the top-level
|
||||
* {@code manifest.json(5)}). Within each tier the per-tier manifest's {@code files}
|
||||
* array determines the file order.
|
||||
*
|
||||
* <p>All resolved paths are checked against the normalised base directory via
|
||||
* {@link #safeResolve}: any entry that would escape the base is silently skipped.
|
||||
*
|
||||
* @return ordered list of existing, in-bounds data files (earliest tier first).
|
||||
*/
|
||||
private List<Path> splitTierFilesInOrder() throws IOException {
|
||||
Path base = source.toAbsolutePath().normalize();
|
||||
List<String> tiers = manifestList(base, "tiers", DEFAULT_TIERS);
|
||||
List<Path> result = new ArrayList<>();
|
||||
|
||||
for (String tier : tiers) {
|
||||
Path tierDir = safeResolve(base, tier);
|
||||
if (tierDir == null || !Files.isDirectory(tierDir)) continue;
|
||||
|
||||
for (String fileName : manifestList(tierDir, "files", List.of())) {
|
||||
Path file = safeResolve(base, tierDir.resolve(fileName).toString());
|
||||
if (file == null || !Files.isRegularFile(file)) continue;
|
||||
result.add(file);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve {@code entry} relative to {@code base} and verify the result stays
|
||||
* inside {@code base} (path-traversal guard).
|
||||
*
|
||||
* @param base the normalised absolute base directory.
|
||||
* @param entry a path string (may be relative or absolute, may contain {@code ..}).
|
||||
* @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise.
|
||||
*/
|
||||
private static Path safeResolve(Path base, String entry) {
|
||||
try {
|
||||
Path resolved = base.resolve(entry).toAbsolutePath().normalize();
|
||||
return resolved.startsWith(base) ? resolved : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the {@code key} string-array from the first manifest file found in {@code dir}
|
||||
* ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback}
|
||||
* if no manifest exists or the key is absent/empty.
|
||||
*/
|
||||
private List<String> manifestList(Path dir, String key, List<String> fallback) {
|
||||
for (String name : MANIFEST_NAMES) {
|
||||
Path m = dir.resolve(name);
|
||||
if (!Files.exists(m)) continue;
|
||||
try {
|
||||
String stripped = FurnidataReader.stripJson5(
|
||||
Files.readString(m, StandardCharsets.UTF_8));
|
||||
com.google.gson.JsonObject obj =
|
||||
com.google.gson.JsonParser.parseString(stripped).getAsJsonObject();
|
||||
if (obj.has(key) && obj.get(key).isJsonArray()) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (com.google.gson.JsonElement el : obj.getAsJsonArray(key))
|
||||
list.add(el.getAsString());
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// bad manifest → fall through to next candidate / fallback
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private void backup(Path target) throws IOException {
|
||||
Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime());
|
||||
Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
pruneBackups(target);
|
||||
}
|
||||
|
||||
private void pruneBackups(Path target) throws IOException {
|
||||
String prefix = target.getFileName() + ".bak.";
|
||||
try (var stream = Files.list(target.getParent())) {
|
||||
List<Path> baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||
.sorted(Comparator.comparingLong(p -> backupStamp(p))).toList();
|
||||
for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static long backupStamp(Path p) {
|
||||
String s = p.getFileName().toString();
|
||||
try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; }
|
||||
}
|
||||
|
||||
private void atomicWrite(Path target, String content) throws IOException {
|
||||
Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime());
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the most recent backup of the (single-file) target. @return true if restored. */
|
||||
public boolean revertLastBackup() throws IOException {
|
||||
if (directory) return revertSplitTier();
|
||||
return revertFile(source);
|
||||
}
|
||||
|
||||
private boolean revertFile(Path target) throws IOException {
|
||||
String prefix = target.getFileName() + ".bak.";
|
||||
try (var stream = Files.list(target.getParent())) {
|
||||
Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||
.max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null);
|
||||
if (latest == null) return false;
|
||||
atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean revertSplitTier() throws IOException {
|
||||
boolean any = false;
|
||||
for (Path f : splitTierFilesInOrder()) any |= revertFile(f);
|
||||
return any;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* In-memory index of furnidata display names, keyed by the lowercased base
|
||||
* classname (the {@code *N} colour-variant suffix is stripped). Read lazily by
|
||||
* {@link Item#getDisplayName()}. Names are sanitized at index time.
|
||||
*
|
||||
* Thread-safety: the index is held behind a {@code volatile} reference; readers
|
||||
* never block; {@link #reindex(List)} builds a fresh map and swaps it atomically.
|
||||
*/
|
||||
public class FurnitureTextProvider {
|
||||
|
||||
private static final int MAX_LEN = 256;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnitureTextProvider.class);
|
||||
private static final long DEFAULT_MAX_BYTES = 64L * 1024 * 1024;
|
||||
|
||||
private final boolean enabled;
|
||||
private volatile Map<String, FurniText> index = Map.of();
|
||||
private volatile Path source;
|
||||
private FurnidataWatcher watcher;
|
||||
|
||||
public FurnitureTextProvider(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/** Production constructor: reads the enable toggle from config. */
|
||||
public FurnitureTextProvider() {
|
||||
this(Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.names.enabled", "true")));
|
||||
}
|
||||
|
||||
/** Resolve the furnidata source from config and build the initial index. Never throws. */
|
||||
public void init() {
|
||||
try {
|
||||
this.source = resolveSource();
|
||||
if (this.source == null) {
|
||||
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name");
|
||||
return;
|
||||
}
|
||||
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||
LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source);
|
||||
|
||||
if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) {
|
||||
if (this.watcher != null) this.watcher.stop();
|
||||
this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES);
|
||||
this.watcher.start();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getSource() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
/** Returns {@code true} when the resolved source is a directory (split-tier layout). */
|
||||
public boolean isSourceDirectory() {
|
||||
return this.source != null && Files.isDirectory(this.source);
|
||||
}
|
||||
|
||||
/** Returns the byte cap used when reading furnidata files. */
|
||||
public long getMaxBytes() {
|
||||
return Long.parseLong(com.eu.habbo.Emulator.getConfig().getValue("items.furnidata.max.bytes", String.valueOf(DEFAULT_MAX_BYTES)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the furnidata from the current source and reindexes atomically.
|
||||
* Returns the delta list (new/changed entries) from {@link #reindex(List)}.
|
||||
* Never throws — returns an empty list when the source is unavailable.
|
||||
*/
|
||||
public java.util.List<FurnidataEntry> reindexFromSource() {
|
||||
try {
|
||||
if (this.source == null) return java.util.List.of();
|
||||
return reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnitureTextProvider.reindexFromSource failed", e);
|
||||
return java.util.List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static Path resolveSource() {
|
||||
String override = Emulator.getConfig().getValue("items.furnidata.path", "");
|
||||
if (!override.isEmpty()) {
|
||||
Path p = Paths.get(override);
|
||||
if (Files.exists(p)) return p;
|
||||
LOGGER.warn("FurnitureTextProvider: items.furnidata.path '{}' does not exist", override);
|
||||
return null;
|
||||
}
|
||||
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
|
||||
if (basePath.isEmpty()) return null;
|
||||
Path dir = Paths.get(basePath);
|
||||
Path split = dir.resolve("furnidata");
|
||||
if (Files.isDirectory(split)) return split;
|
||||
Path legacy = dir.resolve("FurnitureData.json");
|
||||
return Files.exists(legacy) ? legacy : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh sanitized index, swap it in atomically, and return the
|
||||
* changed/added entries (sanitized) as the delta versus the previous index.
|
||||
*/
|
||||
public java.util.List<FurnidataEntry> reindex(java.util.List<FurnidataEntry> entries) {
|
||||
Map<String, FurniText> next = new HashMap<>(Math.max(16, entries.size() * 2));
|
||||
for (FurnidataEntry e : entries) {
|
||||
String key = baseKey(e.classname());
|
||||
if (key == null) continue;
|
||||
next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description())));
|
||||
}
|
||||
|
||||
Map<String, FurniText> prev = this.index;
|
||||
java.util.List<FurnidataEntry> delta = new java.util.ArrayList<>();
|
||||
for (Map.Entry<String, FurniText> en : next.entrySet()) {
|
||||
FurniText cur = en.getValue();
|
||||
FurniText old = prev.get(en.getKey());
|
||||
if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) {
|
||||
delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description()));
|
||||
}
|
||||
}
|
||||
|
||||
this.index = next; // atomic reference swap
|
||||
return delta;
|
||||
}
|
||||
|
||||
/** Returns the sanitized display name for a DB classname, or null if absent/disabled. */
|
||||
public String getName(String classname) {
|
||||
if (!this.enabled) return null;
|
||||
String key = baseKey(classname);
|
||||
if (key == null) return null;
|
||||
FurniText t = this.index.get(key);
|
||||
return (t != null) ? t.name() : null;
|
||||
}
|
||||
|
||||
private static String baseKey(String classname) {
|
||||
if (classname == null) return null;
|
||||
int star = classname.indexOf('*');
|
||||
String base = (star >= 0) ? classname.substring(0, star) : classname;
|
||||
base = base.trim().toLowerCase(Locale.ROOT);
|
||||
return base.isEmpty() ? null : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe).
|
||||
* The 256 cap is in Java {@code char} units (UTF-16 code units), which is acceptable for
|
||||
* furni names (controlled, predominantly ASCII source). Lone/astral surrogates are not
|
||||
* specially handled.
|
||||
*/
|
||||
public static String sanitize(String value) {
|
||||
if (value == null) return "";
|
||||
StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN));
|
||||
for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '%') { sb.append('%'); continue; } // fullwidth percent — not a placeholder token
|
||||
if (c == '\n' || c == '\r' || Character.isISOControl(c)) continue;
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all lowercased base classnames whose furnidata display name contains
|
||||
* {@code query} (case-insensitive, substring). Results are capped at 200 to
|
||||
* bound SQL IN-clause size. Returns an empty list when query is null/blank.
|
||||
*/
|
||||
public java.util.List<String> findClassnamesByName(String query) {
|
||||
java.util.List<String> out = new java.util.ArrayList<>();
|
||||
if (query == null) return out;
|
||||
String q = query.trim().toLowerCase(Locale.ROOT);
|
||||
if (q.isEmpty()) return out;
|
||||
Map<String, FurniText> idx = this.index; // local ref (volatile)
|
||||
for (Map.Entry<String, FurniText> e : idx.entrySet()) {
|
||||
FurniText t = e.getValue();
|
||||
if (t != null && t.name() != null && t.name().toLowerCase(Locale.ROOT).contains(q)) {
|
||||
out.add(e.getKey()); // key is the lowercased base classname
|
||||
if (out.size() >= 200) break; // bound IN-clause size
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private record FurniText(int id, FurnitureType type, String name, String description) {}
|
||||
}
|
||||
@@ -167,6 +167,20 @@ public class Item implements ISerialize {
|
||||
return this.fullName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for user-facing/log output, sourced from furnidata (by classname).
|
||||
* Falls back to the DB public_name when furnidata has no entry or names are disabled.
|
||||
* Never returns null.
|
||||
*/
|
||||
public String getDisplayName() {
|
||||
FurnitureTextProvider provider = (Emulator.getGameEnvironment() != null)
|
||||
? Emulator.getGameEnvironment().getFurnitureTextProvider()
|
||||
: null;
|
||||
String name = (provider != null) ? provider.getName(this.name) : null;
|
||||
if (name != null && !name.isBlank()) return name;
|
||||
return (this.fullName != null) ? this.fullName : "";
|
||||
}
|
||||
|
||||
public FurnitureType getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.eu.habbo.habbohotel.mentions;
|
||||
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public class HabboMention {
|
||||
|
||||
public static final int TYPE_DIRECT = 0;
|
||||
public static final int TYPE_ROOM = 1;
|
||||
|
||||
private final int id;
|
||||
private final int targetUserId;
|
||||
private final int senderUserId;
|
||||
private final String senderUsername;
|
||||
private final int roomId;
|
||||
private final String roomName;
|
||||
private final String message;
|
||||
private final int mentionType;
|
||||
private final int timestamp;
|
||||
private final boolean read;
|
||||
private final String senderFigure;
|
||||
|
||||
public HabboMention(ResultSet set) throws SQLException {
|
||||
this.id = set.getInt("id");
|
||||
this.targetUserId = set.getInt("target_user_id");
|
||||
this.senderUserId = set.getInt("sender_user_id");
|
||||
this.senderUsername = set.getString("sender_username");
|
||||
this.roomId = set.getInt("room_id");
|
||||
this.roomName = set.getString("room_name");
|
||||
this.message = set.getString("message");
|
||||
this.mentionType = set.getInt("mention_type");
|
||||
this.timestamp = set.getInt("timestamp");
|
||||
this.read = set.getInt("read") == 1;
|
||||
this.senderFigure = hasSenderFigure(set) ? set.getString("sender_figure") : "";
|
||||
}
|
||||
|
||||
private static boolean hasSenderFigure(ResultSet set) {
|
||||
try {
|
||||
set.findColumn("sender_figure");
|
||||
return true;
|
||||
} catch (SQLException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public HabboMention(int targetUserId, int id, Habbo sender, Room room, String roomName, String message, int mentionType, int timestamp) {
|
||||
this.id = id;
|
||||
this.targetUserId = targetUserId;
|
||||
this.senderUserId = sender.getHabboInfo().getId();
|
||||
this.senderUsername = sender.getHabboInfo().getUsername();
|
||||
this.roomId = room.getId();
|
||||
this.roomName = roomName;
|
||||
this.message = message;
|
||||
this.mentionType = mentionType;
|
||||
this.timestamp = timestamp;
|
||||
this.read = false;
|
||||
this.senderFigure = sender.getHabboInfo().getLook();
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public int getTargetUserId() {
|
||||
return this.targetUserId;
|
||||
}
|
||||
|
||||
public int getSenderUserId() {
|
||||
return this.senderUserId;
|
||||
}
|
||||
|
||||
public String getSenderUsername() {
|
||||
return this.senderUsername;
|
||||
}
|
||||
|
||||
public int getRoomId() {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
public String getRoomName() {
|
||||
return this.roomName;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
public int getMentionType() {
|
||||
return this.mentionType;
|
||||
}
|
||||
|
||||
public int getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public boolean isRead() {
|
||||
return this.read;
|
||||
}
|
||||
|
||||
public String getSenderFigure() {
|
||||
return this.senderFigure == null ? "" : this.senderFigure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package com.eu.habbo.habbohotel.mentions;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.messenger.MessengerBuddy;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.habbohotel.rooms.RoomChatType;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.habbohotel.users.HabboManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class MentionManager {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(MentionManager.class);
|
||||
|
||||
private static final int ROOM_NAME_MAX_LENGTH = 64;
|
||||
private static final int MESSAGE_MAX_LENGTH = 255;
|
||||
private final ConcurrentHashMap<Integer, Long> cooldowns = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Integer, Long> roomBroadcastCooldowns = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Integer, Long> requestListCooldowns = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Integer, Long> markReadCooldowns = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Integer, Long> markAllCooldowns = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Integer, Long> deleteCooldowns = new ConcurrentHashMap<>();
|
||||
|
||||
private volatile long lastPrune = System.currentTimeMillis();
|
||||
private static final long PRUNE_INTERVAL_MS = 5 * 60_000L;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return Emulator.getConfig().getInt("mentions.enabled", 1) == 1;
|
||||
}
|
||||
|
||||
public enum BroadcastScope {
|
||||
NONE,
|
||||
ROOM,
|
||||
FRIENDS,
|
||||
EVERYONE
|
||||
}
|
||||
|
||||
public static final String PERMISSION_EVERYONE = "acc_mention_everyone";
|
||||
public static final String PERMISSION_FRIENDS = "acc_mention_friends";
|
||||
|
||||
private Set<String> parseAliases(String configKey, String defaultValue) {
|
||||
Set<String> aliases = new HashSet<>();
|
||||
String raw = Emulator.getConfig().getValue(configKey, defaultValue);
|
||||
for (String alias : raw.split(",")) {
|
||||
String trimmed = alias.trim().toLowerCase();
|
||||
if (!trimmed.isEmpty()) {
|
||||
aliases.add(trimmed);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private Set<String> roomAliases() {
|
||||
return parseAliases("mentions.room.aliases", "room,stanza");
|
||||
}
|
||||
|
||||
private Set<String> friendsAliases() {
|
||||
return parseAliases("mentions.friends.aliases", "friends,amici");
|
||||
}
|
||||
|
||||
private Set<String> everyoneAliases() {
|
||||
return parseAliases("mentions.everyone.aliases", "all,everyone,tutti");
|
||||
}
|
||||
|
||||
private BroadcastScope classifyAlias(String alias,
|
||||
Set<String> everyone,
|
||||
Set<String> friends,
|
||||
Set<String> room) {
|
||||
if (alias.isEmpty()) return BroadcastScope.NONE;
|
||||
if (everyone.contains(alias)) return BroadcastScope.EVERYONE;
|
||||
if (friends.contains(alias)) return BroadcastScope.FRIENDS;
|
||||
if (room.contains(alias)) return BroadcastScope.ROOM;
|
||||
return BroadcastScope.NONE;
|
||||
}
|
||||
|
||||
public void process(Habbo sender, Room room, String message, RoomChatType type) {
|
||||
try {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender == null || room == null || message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.isEmpty() || message.indexOf('@') < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int senderId = sender.getHabboInfo().getId();
|
||||
long now = System.currentTimeMillis();
|
||||
long cooldownMs = Emulator.getConfig().getInt("mentions.cooldown.ms", 3000);
|
||||
Long last = this.cooldowns.get(senderId);
|
||||
if (last != null && (now - last) < cooldownMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> roomAliases = this.roomAliases();
|
||||
Set<String> friendsAliases = this.friendsAliases();
|
||||
Set<String> everyoneAliases = this.everyoneAliases();
|
||||
|
||||
BroadcastScope broadcastScope = BroadcastScope.NONE;
|
||||
LinkedHashSet<String> directTokens = new LinkedHashSet<>();
|
||||
|
||||
for (String token : message.split("\\s+")) {
|
||||
if (token.length() < 2 || token.charAt(0) != '@') {
|
||||
continue;
|
||||
}
|
||||
|
||||
String raw = token.substring(1);
|
||||
String aliasCandidate = trimTrailingPunctuation(raw).toLowerCase();
|
||||
|
||||
BroadcastScope scope = this.classifyAlias(aliasCandidate, everyoneAliases, friendsAliases, roomAliases);
|
||||
|
||||
if (scope != BroadcastScope.NONE) {
|
||||
if (scope.ordinal() > broadcastScope.ordinal()) {
|
||||
broadcastScope = scope;
|
||||
}
|
||||
} else if (!raw.isEmpty()) {
|
||||
directTokens.add(raw);
|
||||
}
|
||||
}
|
||||
|
||||
if (broadcastScope == BroadcastScope.EVERYONE && !sender.hasPermission(PERMISSION_EVERYONE)) {
|
||||
broadcastScope = BroadcastScope.NONE;
|
||||
} else if (broadcastScope == BroadcastScope.FRIENDS && !sender.hasPermission(PERMISSION_FRIENDS)) {
|
||||
broadcastScope = BroadcastScope.NONE;
|
||||
}
|
||||
|
||||
if (broadcastScope == BroadcastScope.NONE && directTokens.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (broadcastScope != BroadcastScope.NONE) {
|
||||
long roomCooldownMs = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000);
|
||||
Long lastRoom = this.roomBroadcastCooldowns.get(senderId);
|
||||
if (lastRoom != null && (now - lastRoom) < roomCooldownMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int maxTargets = Emulator.getConfig().getInt("mentions.max.targets", 50);
|
||||
if (maxTargets <= 0) maxTargets = 1;
|
||||
int maxDirectTokens = Math.min(directTokens.size(), maxTargets);
|
||||
|
||||
List<Habbo> targets = new ArrayList<>();
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
|
||||
switch (broadcastScope) {
|
||||
case EVERYONE:
|
||||
this.collectEveryoneTargets(senderId, targets, seen, maxTargets);
|
||||
break;
|
||||
case FRIENDS:
|
||||
this.collectFriendsTargets(sender, senderId, targets, seen, maxTargets);
|
||||
break;
|
||||
case ROOM:
|
||||
this.collectRoomTargets(room, senderId, targets, seen, maxTargets, true);
|
||||
break;
|
||||
case NONE:
|
||||
default:
|
||||
int processed = 0;
|
||||
for (String token : directTokens) {
|
||||
if (processed++ >= maxDirectTokens) break;
|
||||
Habbo habbo = this.resolveHabbo(room, token);
|
||||
if (habbo == null || habbo.getHabboInfo().getId() == senderId) {
|
||||
continue;
|
||||
}
|
||||
if (!acceptsMention(habbo, false)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.add(habbo.getHabboInfo().getId())) {
|
||||
targets.add(habbo);
|
||||
}
|
||||
if (targets.size() >= maxTargets) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cooldowns.put(senderId, now);
|
||||
if (broadcastScope != BroadcastScope.NONE) this.roomBroadcastCooldowns.put(senderId, now);
|
||||
this.pruneCooldownsIfDue(now);
|
||||
|
||||
int mentionType = (broadcastScope != BroadcastScope.NONE) ? HabboMention.TYPE_ROOM : HabboMention.TYPE_DIRECT;
|
||||
int timestamp = Emulator.getIntUnixTimestamp();
|
||||
String roomName = truncate(room.getName(), ROOM_NAME_MAX_LENGTH);
|
||||
String storedMessage = truncate(message, MESSAGE_MAX_LENGTH);
|
||||
|
||||
for (Habbo target : targets) {
|
||||
this.store(target, sender, room, storedMessage, mentionType, timestamp, roomName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to process mentions.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectRoomTargets(Room room, int senderId, List<Habbo> targets, Set<Integer> seen, int maxTargets, boolean isBroadcast) {
|
||||
for (Habbo habbo : room.getHabbos()) {
|
||||
if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue;
|
||||
if (!acceptsMention(habbo, isBroadcast)) continue;
|
||||
if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo);
|
||||
if (targets.size() >= maxTargets) break;
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFriendsTargets(Habbo sender, int senderId, List<Habbo> targets, Set<Integer> seen, int maxTargets) {
|
||||
if (sender.getMessenger() == null) return;
|
||||
HabboManager habboManager = Emulator.getGameEnvironment().getHabboManager();
|
||||
for (MessengerBuddy buddy : sender.getMessenger().getFriends().values()) {
|
||||
if (buddy == null) continue;
|
||||
int buddyId = buddy.getId();
|
||||
if (buddyId == senderId) continue;
|
||||
Habbo online = habboManager.getHabbo(buddyId);
|
||||
if (online == null) continue;
|
||||
if (!acceptsMention(online, true)) continue;
|
||||
if (seen.add(buddyId)) targets.add(online);
|
||||
if (targets.size() >= maxTargets) break;
|
||||
}
|
||||
}
|
||||
|
||||
private void collectEveryoneTargets(int senderId, List<Habbo> targets, Set<Integer> seen, int maxTargets) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue;
|
||||
if (!acceptsMention(habbo, true)) continue;
|
||||
if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo);
|
||||
if (targets.size() >= maxTargets) break;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean acceptsMention(Habbo recipient, boolean isBroadcast) {
|
||||
if (recipient == null) return false;
|
||||
if (recipient.getClient() == null) return false;
|
||||
if (recipient.getHabboStats() == null) return false;
|
||||
if (!recipient.getHabboStats().mentionsEnabled()) return false;
|
||||
if (isBroadcast && !recipient.getHabboStats().massMentionsEnabled()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void store(Habbo target, Habbo sender, Room room, String message, int mentionType, int timestamp, String roomName) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO habbo_mentions (target_user_id, sender_user_id, sender_username, room_id, room_name, message, mention_type, timestamp, `read`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)",
|
||||
Statement.RETURN_GENERATED_KEYS)) {
|
||||
statement.setInt(1, target.getHabboInfo().getId());
|
||||
statement.setInt(2, sender.getHabboInfo().getId());
|
||||
statement.setString(3, sender.getHabboInfo().getUsername());
|
||||
statement.setInt(4, room.getId());
|
||||
statement.setString(5, roomName);
|
||||
statement.setString(6, message);
|
||||
statement.setInt(7, mentionType);
|
||||
statement.setInt(8, timestamp);
|
||||
statement.executeUpdate();
|
||||
|
||||
int generatedId = 0;
|
||||
try (ResultSet keys = statement.getGeneratedKeys()) {
|
||||
if (keys.next()) {
|
||||
generatedId = keys.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
HabboMention mention = new HabboMention(target.getHabboInfo().getId(), generatedId, sender, room, roomName, message, mentionType, timestamp);
|
||||
|
||||
if (target.getClient() != null) {
|
||||
target.getClient().sendResponse(new com.eu.habbo.messages.outgoing.mentions.MentionReceivedComposer(mention));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Failed to store mention.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<HabboMention> getMentions(int userId, int limit) {
|
||||
List<HabboMention> mentions = new ArrayList<>();
|
||||
if (limit <= 0) limit = 50;
|
||||
if (limit > 200) limit = 200;
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT habbo_mentions.*, users.look AS sender_figure FROM habbo_mentions LEFT JOIN users ON users.id = habbo_mentions.sender_user_id WHERE target_user_id = ? ORDER BY id DESC LIMIT ?")) {
|
||||
statement.setInt(1, userId);
|
||||
statement.setInt(2, limit);
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
while (set.next()) {
|
||||
mentions.add(new HabboMention(set));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Failed to load mentions.", e);
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
public void markRead(int userId, int mode, int mentionId) {
|
||||
if (mode != 0 && mode != 1) return;
|
||||
if (mode == 1 && mentionId <= 0) return;
|
||||
|
||||
String query = mode == 1
|
||||
? "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND id = ? AND `read` = 0"
|
||||
: "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND `read` = 0";
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(query)) {
|
||||
statement.setInt(1, userId);
|
||||
if (mode == 1) {
|
||||
statement.setInt(2, mentionId);
|
||||
}
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Failed to mark mentions as read.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(int userId, int mentionId) {
|
||||
if (mentionId <= 0) return;
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"DELETE FROM habbo_mentions WHERE target_user_id = ? AND id = ?")) {
|
||||
statement.setInt(1, userId);
|
||||
statement.setInt(2, mentionId);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Failed to delete mention.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean tryAcquireRequestList(int userId) {
|
||||
long cooldownMs = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000);
|
||||
return tryAcquire(this.requestListCooldowns, userId, cooldownMs);
|
||||
}
|
||||
|
||||
public boolean tryAcquireMarkRead(int userId, int mode) {
|
||||
long cooldownMs;
|
||||
ConcurrentHashMap<Integer, Long> bucket;
|
||||
if (mode == 1) {
|
||||
cooldownMs = Emulator.getConfig().getInt("mentions.markread.cooldown.ms", 500);
|
||||
bucket = this.markReadCooldowns;
|
||||
} else {
|
||||
cooldownMs = Emulator.getConfig().getInt("mentions.markall.cooldown.ms", 5000);
|
||||
bucket = this.markAllCooldowns;
|
||||
}
|
||||
return tryAcquire(bucket, userId, cooldownMs);
|
||||
}
|
||||
|
||||
public boolean tryAcquireDelete(int userId) {
|
||||
long cooldownMs = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500);
|
||||
return tryAcquire(this.deleteCooldowns, userId, cooldownMs);
|
||||
}
|
||||
|
||||
private boolean tryAcquire(ConcurrentHashMap<Integer, Long> bucket, int userId, long cooldownMs) {
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = bucket.get(userId);
|
||||
if (last != null && (now - last) < cooldownMs) {
|
||||
return false;
|
||||
}
|
||||
bucket.put(userId, now);
|
||||
this.pruneCooldownsIfDue(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void pruneCooldownsIfDue(long now) {
|
||||
if (now - this.lastPrune < PRUNE_INTERVAL_MS) return;
|
||||
this.lastPrune = now;
|
||||
|
||||
long mentionWindow = Emulator.getConfig().getInt("mentions.cooldown.ms", 3000);
|
||||
long roomWindow = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000);
|
||||
long requestWindow = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000);
|
||||
long markReadWindow = Emulator.getConfig().getInt("mentions.markread.cooldown.ms", 500);
|
||||
long markAllWindow = Emulator.getConfig().getInt("mentions.markall.cooldown.ms", 5000);
|
||||
long deleteWindow = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500);
|
||||
|
||||
prune(this.cooldowns, now, mentionWindow);
|
||||
prune(this.roomBroadcastCooldowns, now, roomWindow);
|
||||
prune(this.requestListCooldowns, now, requestWindow);
|
||||
prune(this.markReadCooldowns, now, markReadWindow);
|
||||
prune(this.markAllCooldowns, now, markAllWindow);
|
||||
prune(this.deleteCooldowns, now, deleteWindow);
|
||||
}
|
||||
|
||||
private static void prune(ConcurrentHashMap<Integer, Long> bucket, long now, long windowMs) {
|
||||
Iterator<Map.Entry<Integer, Long>> it = bucket.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<Integer, Long> entry = it.next();
|
||||
Long value = entry.getValue();
|
||||
if (value == null || (now - value) >= windowMs) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String TRAILING_PUNCTUATION = ".,!?;:)]}\"'";
|
||||
|
||||
private static String trimTrailingPunctuation(String value) {
|
||||
int end = value.length();
|
||||
while (end > 0 && TRAILING_PUNCTUATION.indexOf(value.charAt(end - 1)) >= 0) {
|
||||
end--;
|
||||
}
|
||||
return value.substring(0, end);
|
||||
}
|
||||
|
||||
private static String truncate(String value, int max) {
|
||||
if (value == null) return "";
|
||||
if (value.length() <= max) return value;
|
||||
return value.substring(0, max);
|
||||
}
|
||||
|
||||
private boolean isBotOrPetName(Room room, String token) {
|
||||
if (room == null || token == null || token.isEmpty()) return false;
|
||||
|
||||
List<com.eu.habbo.habbohotel.bots.Bot> bots = room.getBots(token);
|
||||
if (bots != null && !bots.isEmpty()) return true;
|
||||
|
||||
if (room.getUnitManager() != null && room.getUnitManager().getPets() != null) {
|
||||
for (com.eu.habbo.habbohotel.pets.Pet pet : room.getUnitManager().getPets()) {
|
||||
if (pet != null && pet.getName() != null && pet.getName().equalsIgnoreCase(token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Habbo resolveHabbo(Room room, String rawToken) {
|
||||
if (isBotOrPetName(room, rawToken)) {
|
||||
return null;
|
||||
}
|
||||
String trimmedForBotCheck = trimTrailingPunctuation(rawToken);
|
||||
if (!trimmedForBotCheck.equals(rawToken) && isBotOrPetName(room, trimmedForBotCheck)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Habbo habbo = room.getHabbo(rawToken);
|
||||
if (habbo != null) {
|
||||
return habbo;
|
||||
}
|
||||
|
||||
HabboManager habboManager = Emulator.getGameEnvironment().getHabboManager();
|
||||
habbo = habboManager.getHabbo(rawToken);
|
||||
if (habbo != null) {
|
||||
return habbo;
|
||||
}
|
||||
String trimmed = trimTrailingPunctuation(rawToken);
|
||||
if (!trimmed.isEmpty() && !trimmed.equals(rawToken)) {
|
||||
habbo = room.getHabbo(trimmed);
|
||||
if (habbo != null) {
|
||||
return habbo;
|
||||
}
|
||||
return habboManager.getHabbo(trimmed);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ public class WordFilter {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(WordFilter.class);
|
||||
|
||||
private static final Pattern DIACRITICS_AND_FRIENDS = Pattern.compile("[\\p{InCombiningDiacriticalMarks}\\p{IsLm}\\p{IsSk}]+");
|
||||
//Configuration. Loaded from database & updated accordingly.
|
||||
public static boolean ENABLED_FRIENDCHAT = true;
|
||||
public static String DEFAULT_REPLACEMENT = "bobba";
|
||||
protected THashSet<WordFilterWord> autoReportWords = new THashSet<>();
|
||||
@@ -63,10 +62,12 @@ public class WordFilter {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (word.autoReport)
|
||||
this.autoReportWords.add(word);
|
||||
else if (word.hideMessage)
|
||||
this.hideMessageWords.add(word);
|
||||
if (!word.prefixOnly) {
|
||||
if (word.autoReport)
|
||||
this.autoReportWords.add(word);
|
||||
else if (word.hideMessage)
|
||||
this.hideMessageWords.add(word);
|
||||
}
|
||||
|
||||
this.words.add(word);
|
||||
}
|
||||
@@ -146,6 +147,8 @@ public class WordFilter {
|
||||
while (iterator.hasNext()) {
|
||||
WordFilterWord word = (WordFilterWord) iterator.next();
|
||||
|
||||
if (word.prefixOnly) continue;
|
||||
|
||||
if (StringUtils.containsIgnoreCase(filteredMessage, word.key)) {
|
||||
if (habbo != null) {
|
||||
if (Emulator.getPluginManager().fireEvent(new UserTriggerWordFilterEvent(habbo, word)).isCancelled())
|
||||
@@ -179,6 +182,8 @@ public class WordFilter {
|
||||
while (iterator.hasNext()) {
|
||||
WordFilterWord word = (WordFilterWord) iterator.next();
|
||||
|
||||
if (word.prefixOnly) continue;
|
||||
|
||||
if (StringUtils.containsIgnoreCase(message, word.key)) {
|
||||
if (habbo != null) {
|
||||
if (Emulator.getPluginManager().fireEvent(new UserTriggerWordFilterEvent(habbo, word)).isCancelled())
|
||||
|
||||
@@ -9,6 +9,7 @@ public class WordFilterWord {
|
||||
public final boolean hideMessage;
|
||||
public final boolean autoReport;
|
||||
public final int muteTime;
|
||||
public final boolean prefixOnly;
|
||||
|
||||
public WordFilterWord(ResultSet set) throws SQLException {
|
||||
this.key = set.getString("key");
|
||||
@@ -16,13 +17,27 @@ public class WordFilterWord {
|
||||
this.hideMessage = set.getInt("hide") == 1;
|
||||
this.autoReport = set.getInt("report") == 1;
|
||||
this.muteTime = set.getInt("mute");
|
||||
this.prefixOnly = readBooleanColumn(set, "prefix_only");
|
||||
}
|
||||
|
||||
public WordFilterWord(String key, String replacement) {
|
||||
this(key, replacement, false);
|
||||
}
|
||||
|
||||
public WordFilterWord(String key, String replacement, boolean prefixOnly) {
|
||||
this.key = key;
|
||||
this.replacement = replacement;
|
||||
this.hideMessage = false;
|
||||
this.autoReport = false;
|
||||
this.muteTime = 0;
|
||||
this.prefixOnly = prefixOnly;
|
||||
}
|
||||
|
||||
private static boolean readBooleanColumn(ResultSet set, String column) {
|
||||
try {
|
||||
return set.getInt(column) == 1;
|
||||
} catch (SQLException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ public class HabboStats implements Runnable {
|
||||
public boolean hasGottenDefaultSavedSearches;
|
||||
private HabboInfo habboInfo;
|
||||
private boolean allowTrade;
|
||||
private boolean mentionsEnabled;
|
||||
private boolean massMentionsEnabled;
|
||||
private int clubExpireTimestamp;
|
||||
private int muteEndTime;
|
||||
public int maxFriends;
|
||||
@@ -131,6 +133,8 @@ public class HabboStats implements Runnable {
|
||||
this.guilds = new ArrayList<>();
|
||||
this.tags = set.getString("tags").split(";");
|
||||
this.allowTrade = set.getString("can_trade").equals("1");
|
||||
this.mentionsEnabled = "1".equals(safeColumnString(set, "mentions_enabled", "1"));
|
||||
this.massMentionsEnabled = "1".equals(safeColumnString(set, "mass_mentions_enabled", "1"));
|
||||
this.votedRooms = new TIntArrayStack();
|
||||
this.clubExpireTimestamp = set.getInt("club_expire_timestamp");
|
||||
this.loginStreak = set.getInt("login_streak");
|
||||
@@ -749,13 +753,6 @@ public class HabboStats implements Runnable {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore an user.
|
||||
*
|
||||
* @param gameClient The client to which this HabboStats instance belongs.
|
||||
* @param userId The user to ignore.
|
||||
* @return true if successfully ignored, false otherwise.
|
||||
*/
|
||||
public boolean ignoreUser(GameClient gameClient, int userId) {
|
||||
final Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId);
|
||||
|
||||
@@ -805,6 +802,44 @@ public class HabboStats implements Runnable {
|
||||
else return this.allowTrade;
|
||||
}
|
||||
|
||||
public boolean mentionsEnabled() {
|
||||
return this.mentionsEnabled;
|
||||
}
|
||||
|
||||
public boolean massMentionsEnabled() {
|
||||
return this.massMentionsEnabled;
|
||||
}
|
||||
|
||||
public void setMentionsEnabled(boolean enabled) {
|
||||
this.mentionsEnabled = enabled;
|
||||
persistFlag("mentions_enabled", enabled);
|
||||
}
|
||||
|
||||
public void setMassMentionsEnabled(boolean enabled) {
|
||||
this.massMentionsEnabled = enabled;
|
||||
persistFlag("mass_mentions_enabled", enabled);
|
||||
}
|
||||
|
||||
private void persistFlag(String column, boolean enabled) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET `" + column + "` = ? WHERE user_id = ? LIMIT 1")) {
|
||||
statement.setString(1, enabled ? "1" : "0");
|
||||
statement.setInt(2, this.habboInfo.getId());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Failed to persist users_settings.{} for user {}", column, this.habboInfo.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeColumnString(ResultSet set, String column, String defaultValue) {
|
||||
try {
|
||||
String value = set.getString(column);
|
||||
return value == null ? defaultValue : value;
|
||||
} catch (SQLException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public void setAllowTrade(boolean allowTrade) {
|
||||
this.allowTrade = allowTrade;
|
||||
}
|
||||
|
||||
+1
-1
@@ -279,7 +279,7 @@ public final class WiredTextPlaceholderUtil {
|
||||
continue;
|
||||
}
|
||||
|
||||
String furniName = item.getBaseItem().getFullName();
|
||||
String furniName = item.getBaseItem().getDisplayName();
|
||||
if (furniName == null || furniName.trim().isEmpty()) {
|
||||
furniName = item.getBaseItem().getName();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.eu.habbo.messages.incoming.hotelview.*;
|
||||
import com.eu.habbo.messages.incoming.inventory.*;
|
||||
import com.eu.habbo.messages.incoming.inventory.nickicons.*;
|
||||
import com.eu.habbo.messages.incoming.inventory.prefixes.*;
|
||||
import com.eu.habbo.messages.incoming.mentions.*;
|
||||
import com.eu.habbo.messages.incoming.modtool.*;
|
||||
import com.eu.habbo.messages.incoming.navigator.*;
|
||||
import com.eu.habbo.messages.incoming.polls.AnswerPollEvent;
|
||||
@@ -284,6 +285,9 @@ public class PacketManager {
|
||||
this.registerHandler(Incoming.FurniEditorInteractionsEvent, FurniEditorInteractionsEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class);
|
||||
this.registerHandler(Incoming.FurniEditorImportTextEvent, FurniEditorImportTextEvent.class);
|
||||
|
||||
// Catalog Admin
|
||||
this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class);
|
||||
@@ -297,6 +301,8 @@ public class PacketManager {
|
||||
this.registerHandler(Incoming.CatalogAdminPublishEvent, CatalogAdminPublishEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminSavePageImagesEvent, CatalogAdminSavePageImagesEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminSavePageIconEvent, CatalogAdminSavePageIconEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminLoadOfferEvent, CatalogAdminLoadOfferEvent.class);
|
||||
this.registerHandler(Incoming.CatalogAdminLoadPageEvent, CatalogAdminLoadPageEvent.class);
|
||||
}
|
||||
|
||||
private void registerEvent() throws Exception {
|
||||
@@ -426,6 +432,9 @@ public class PacketManager {
|
||||
}
|
||||
|
||||
void registerRooms() throws Exception {
|
||||
this.registerHandler(Incoming.RequestMentionsEvent, RequestMentionsEvent.class);
|
||||
this.registerHandler(Incoming.MarkMentionsReadEvent, MarkMentionsReadEvent.class);
|
||||
this.registerHandler(Incoming.DeleteMentionEvent, DeleteMentionEvent.class);
|
||||
this.registerHandler(Incoming.RequestRoomLoadEvent, RequestRoomLoadEvent.class);
|
||||
this.registerHandler(Incoming.RequestHeightmapEvent, RequestRoomHeightmapEvent.class);
|
||||
this.registerHandler(Incoming.RequestRoomHeightmapEvent, RequestRoomHeightmapEvent.class);
|
||||
|
||||
@@ -431,6 +431,9 @@ public class Incoming {
|
||||
public static final int FurniEditorInteractionsEvent = 10043;
|
||||
public static final int FurniEditorUpdateEvent = 10044;
|
||||
public static final int FurniEditorDeleteEvent = 10045;
|
||||
public static final int FurniEditorUpdateFurnidataEvent = 10046;
|
||||
public static final int FurniEditorRevertFurnidataEvent = 10048;
|
||||
public static final int FurniEditorImportTextEvent = 10049;
|
||||
|
||||
// Catalog Admin
|
||||
public static final int CatalogAdminSavePageEvent = 10050;
|
||||
@@ -444,6 +447,8 @@ public class Incoming {
|
||||
public static final int CatalogAdminPublishEvent = 10058;
|
||||
public static final int CatalogAdminSavePageImagesEvent = 10060;
|
||||
public static final int CatalogAdminSavePageIconEvent = 10061;
|
||||
public static final int CatalogAdminLoadOfferEvent = 10062;
|
||||
public static final int CatalogAdminLoadPageEvent = 10063;
|
||||
|
||||
// Custom Prefixes
|
||||
public static final int RequestUserPrefixesEvent = 7011;
|
||||
@@ -496,4 +501,7 @@ public class Incoming {
|
||||
public static final int WheelAdminSavePrizesEvent = 9305;
|
||||
public static final int SoundboardPlayEvent = 9306;
|
||||
public static final int SoundboardSetEnabledEvent = 9307;
|
||||
public static final int RequestMentionsEvent = 4803;
|
||||
public static final int MarkMentionsReadEvent = 4804;
|
||||
public static final int DeleteMentionEvent = 4805;
|
||||
}
|
||||
|
||||
+2
-2
@@ -248,7 +248,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
|
||||
LOGGER.debug("sender reached daily total LTD limit");
|
||||
this.client.getHabbo().alert(
|
||||
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total")
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getFullName())
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName())
|
||||
.replace("%limit%", ltdLimit + "")
|
||||
);
|
||||
return;
|
||||
@@ -259,7 +259,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
|
||||
LOGGER.debug("sender reached daily LTD item limit");
|
||||
this.client.getHabbo().alert(
|
||||
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item")
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getFullName())
|
||||
.replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName())
|
||||
.replace("%limit%", ltdLimit + "")
|
||||
);
|
||||
return;
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminOfferDetailsComposer;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminResultComposer;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public class CatalogAdminLoadOfferEvent extends MessageHandler {
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
int offerId = this.packet.readInt();
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
|
||||
String sql = (pageType == CatalogPageType.BUILDER)
|
||||
? "SELECT id, order_number FROM catalog_items_bc WHERE id = ? LIMIT 1"
|
||||
: "SELECT id, offer_id, limited_stack, order_number FROM catalog_items WHERE id = ? LIMIT 1";
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, offerId);
|
||||
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
if (!set.next()) return;
|
||||
|
||||
if (pageType == CatalogPageType.BUILDER) {
|
||||
this.client.sendResponse(new CatalogAdminOfferDetailsComposer(
|
||||
set.getInt("id"),
|
||||
0,
|
||||
0,
|
||||
set.getInt("order_number")
|
||||
));
|
||||
} else {
|
||||
this.client.sendResponse(new CatalogAdminOfferDetailsComposer(
|
||||
set.getInt("id"),
|
||||
set.getInt("offer_id"),
|
||||
set.getInt("limited_stack"),
|
||||
set.getInt("order_number")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPage;
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminPageDetailsComposer;
|
||||
import com.eu.habbo.messages.outgoing.catalog.catalogadmin.CatalogAdminResultComposer;
|
||||
|
||||
public class CatalogAdminLoadPageEvent extends MessageHandler {
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
int pageId = this.packet.readInt();
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
|
||||
CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType);
|
||||
if (page == null) return;
|
||||
|
||||
this.client.sendResponse(new CatalogAdminPageDetailsComposer(page));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -82,7 +82,7 @@ public class FurniEditorHelper {
|
||||
* Prevents SQL injection via arbitrary column names.
|
||||
*/
|
||||
public static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
|
||||
"item_name", "public_name", "sprite_id", "type", "width", "length",
|
||||
"public_name", "sprite_id", "type", "width", "length",
|
||||
"stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay",
|
||||
"allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell",
|
||||
"allow_inventory_stack", "interaction_type", "interaction_modes_count",
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorImportTextResultComposer;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Incoming 10049 — admin imports the official Habbo display name/description for a
|
||||
* furni's classname from a configured furnidata URL (e.g.
|
||||
* https://www.habbo.it/gamedata/furnidata_json/1). The fetched text only POPULATES
|
||||
* the editor fields client-side; the admin reviews and Saves via the normal flow.
|
||||
*
|
||||
* Source URL is admin-configured in emulator_settings ({@code furni.editor.import.url}),
|
||||
* never supplied by the client (no SSRF). The remote furnidata is cached with a TTL.
|
||||
*/
|
||||
public class FurniEditorImportTextEvent extends MessageHandler {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorImportTextEvent.class);
|
||||
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
|
||||
|
||||
// Shared TTL cache (the remote furnidata is multi-MB — do not refetch per click).
|
||||
private static volatile JsonObject CACHE;
|
||||
private static volatile String CACHE_URL;
|
||||
private static volatile long CACHE_TIME;
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
int itemId = this.packet.readInt();
|
||||
if (itemId <= 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
|
||||
if (classname == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
|
||||
return;
|
||||
}
|
||||
String cn = classname.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
String url = Emulator.getConfig().getValue(
|
||||
"furni.editor.import.url", "https://www.habbo.it/gamedata/furnidata_json/1");
|
||||
if (url == null || !(url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Import source not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject root = fetchCached(url);
|
||||
if (root == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Could not fetch Habbo furnidata"));
|
||||
return;
|
||||
}
|
||||
|
||||
String foundName = null, foundDesc = null;
|
||||
outer:
|
||||
for (String section : SECTIONS) {
|
||||
if (!root.has(section) || !root.get(section).isJsonObject()) continue;
|
||||
JsonObject sec = root.getAsJsonObject(section);
|
||||
if (!sec.has("furnitype") || !sec.get("furnitype").isJsonArray()) continue;
|
||||
for (JsonElement el : sec.getAsJsonArray("furnitype")) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
if (!o.has("classname")) continue;
|
||||
if (o.get("classname").getAsString().trim().toLowerCase(Locale.ROOT).equals(cn)) {
|
||||
foundName = (o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : "";
|
||||
foundDesc = (o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : "";
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean found = (foundName != null);
|
||||
this.client.sendResponse(new FurniEditorImportTextResultComposer(
|
||||
found, found ? foundName : "", found ? foundDesc : "", classname));
|
||||
LOGGER.info("FurniEditorImportTextEvent: admin {} import for classname '{}' (item {}) -> found={}",
|
||||
this.client.getHabbo().getHabboInfo().getId(), classname, itemId, found);
|
||||
}
|
||||
|
||||
/** Fetch the remote furnidata JSON with a TTL cache (serves stale on failure). */
|
||||
private static synchronized JsonObject fetchCached(String url) {
|
||||
long ttlMs;
|
||||
try {
|
||||
ttlMs = Long.parseLong(Emulator.getConfig().getValue("furni.editor.import.cache.ms", "600000"));
|
||||
} catch (Exception e) {
|
||||
ttlMs = 600000L;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (CACHE != null && url.equals(CACHE_URL) && (now - CACHE_TIME) < ttlMs) {
|
||||
return CACHE;
|
||||
}
|
||||
|
||||
try {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("User-Agent", "Arcturus-FurniEditor")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) {
|
||||
LOGGER.warn("FurniEditorImportTextEvent: fetch {} returned HTTP {}", url, resp.statusCode());
|
||||
return CACHE; // serve stale if available
|
||||
}
|
||||
JsonObject root = JsonParser.parseString(resp.body()).getAsJsonObject();
|
||||
CACHE = root;
|
||||
CACHE_URL = url;
|
||||
CACHE_TIME = now;
|
||||
return root;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurniEditorImportTextEvent: failed to fetch {}", url, e);
|
||||
return CACHE; // serve stale if available
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataLock;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataWriter;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Incoming handler 10048 — admin reverts a furni's furnidata to the last rotating backup.
|
||||
*
|
||||
* Flow: permission check → read item_id → resolve classname → under FurnidataLock:
|
||||
* FurnidataWriter.revertLastBackup → FurnitureTextProvider.reindexFromSource →
|
||||
* broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
|
||||
*/
|
||||
public class FurniEditorRevertFurnidataEvent extends MessageHandler {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorRevertFurnidataEvent.class);
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
Habbo habbo = this.client.getHabbo();
|
||||
|
||||
// 1. Permission check
|
||||
if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Read packet
|
||||
int itemId = this.packet.readInt();
|
||||
|
||||
if (itemId <= 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Resolve classname from item_id (reuse static helper from update handler)
|
||||
String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
|
||||
String classnameForLog = (classname != null) ? classname : "?";
|
||||
|
||||
// 4. Verify provider is configured
|
||||
FurnitureTextProvider provider =
|
||||
Emulator.getGameEnvironment().getFurnitureTextProvider();
|
||||
|
||||
if (provider == null || provider.getSource() == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
int adminId = habbo.getHabboInfo().getId();
|
||||
|
||||
// 5. Revert + reindex + broadcast under the shared lock
|
||||
boolean reverted;
|
||||
List<FurnidataEntry> delta;
|
||||
|
||||
FurnidataLock.LOCK.lock();
|
||||
try {
|
||||
FurnidataWriter writer = new FurnidataWriter(
|
||||
provider.getSource(),
|
||||
provider.isSourceDirectory(),
|
||||
provider.getMaxBytes(),
|
||||
3 /* backupKeep */
|
||||
);
|
||||
reverted = writer.revertLastBackup();
|
||||
if (!reverted) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No backup found to revert"));
|
||||
return;
|
||||
}
|
||||
|
||||
delta = provider.reindexFromSource();
|
||||
|
||||
if (!delta.isEmpty()) {
|
||||
int deltaCap = Integer.parseInt(
|
||||
Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
broadcastToAll(composer);
|
||||
}
|
||||
} finally {
|
||||
FurnidataLock.LOCK.unlock();
|
||||
}
|
||||
|
||||
// 6. Audit log (outside lock — DB write, not latency-sensitive)
|
||||
FurnidataAuditLog.record(
|
||||
adminId,
|
||||
classnameForLog,
|
||||
"revert",
|
||||
"", // previous state unknown at this point
|
||||
"",
|
||||
"",
|
||||
""
|
||||
);
|
||||
|
||||
// 7. Respond success
|
||||
this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata reverted", itemId));
|
||||
LOGGER.info("FurniEditorRevertFurnidataEvent: admin {} reverted furnidata for classname '{}' (item {})",
|
||||
adminId, classnameForLog, itemId);
|
||||
}
|
||||
|
||||
private static void broadcastToAll(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -27,6 +27,8 @@ public class FurniEditorSearchEvent extends MessageHandler {
|
||||
String query = this.packet.readString();
|
||||
String type = this.packet.readString();
|
||||
int page = this.packet.readInt();
|
||||
String sortField = this.packet.readString();
|
||||
String sortDir = this.packet.readString();
|
||||
|
||||
// Input validation
|
||||
if (query.length() > 100) {
|
||||
@@ -64,10 +66,53 @@ public class FurniEditorSearchEvent extends MessageHandler {
|
||||
params.add(type);
|
||||
}
|
||||
|
||||
// Extend search with furnidata display-name matches (server-authoritative names in JSON).
|
||||
// Appends: OR (LOWER(item_name) IN (?,?,...) [AND type=?])
|
||||
// Both branches carry their own type filter, so type scoping is preserved.
|
||||
// Params: [existing LIKE params] [existing type?] [furniCns...] [type again?]
|
||||
if (!query.isEmpty()) {
|
||||
java.util.List<String> furniCns = Emulator.getGameEnvironment()
|
||||
.getFurnitureTextProvider()
|
||||
.findClassnamesByName(query);
|
||||
if (!furniCns.isEmpty()) {
|
||||
// Build: OR (LOWER(item_name) IN (?,?,...) [AND type = ?])
|
||||
StringBuilder orBranch = new StringBuilder(" OR (LOWER(item_name) IN (");
|
||||
for (int i = 0; i < furniCns.size(); i++) {
|
||||
if (i > 0) orBranch.append(", ");
|
||||
orBranch.append('?');
|
||||
}
|
||||
orBranch.append(')');
|
||||
if (type != null && !type.isEmpty()) {
|
||||
orBranch.append(" AND type = ?");
|
||||
}
|
||||
orBranch.append(')');
|
||||
whereClause.append(orBranch);
|
||||
params.addAll(furniCns);
|
||||
if (type != null && !type.isEmpty()) {
|
||||
params.add(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a SAFE ORDER BY from the whitelisted sort field/direction
|
||||
// (column names are never taken from raw user input — injection-proof).
|
||||
String orderColumn;
|
||||
switch (sortField == null ? "" : sortField) {
|
||||
case "spriteId": orderColumn = "sprite_id"; break;
|
||||
case "itemName": orderColumn = "item_name"; break;
|
||||
case "publicName": orderColumn = "public_name"; break;
|
||||
case "type": orderColumn = "type"; break;
|
||||
case "interactionType": orderColumn = "interaction_type"; break;
|
||||
case "id":
|
||||
default: orderColumn = "id"; break;
|
||||
}
|
||||
String orderDir = "desc".equalsIgnoreCase(sortDir) ? "DESC" : "ASC";
|
||||
|
||||
// Count total
|
||||
int total = 0;
|
||||
String countSql = "SELECT COUNT(*) FROM items_base " + whereClause;
|
||||
String dataSql = "SELECT * FROM items_base " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?";
|
||||
String dataSql = "SELECT * FROM items_base " + whereClause
|
||||
+ " ORDER BY " + orderColumn + " " + orderDir + ", id ASC LIMIT ? OFFSET ?";
|
||||
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataLock;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataWriter;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Incoming handler 10046 — admin saves a furni name/description in the editor.
|
||||
*
|
||||
* Flow: permission check → rate-limit → resolve classname from item_id →
|
||||
* under FurnidataLock: FurnidataWriter.write → FurnitureTextProvider.reindexFromSource →
|
||||
* broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
|
||||
*/
|
||||
public class FurniEditorUpdateFurnidataEvent extends MessageHandler {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorUpdateFurnidataEvent.class);
|
||||
|
||||
/** Rate-limit: min milliseconds between successive calls per admin user id. */
|
||||
private static final long RATE_LIMIT_MS = 1_000L;
|
||||
|
||||
/** Per-admin last-call timestamp map. */
|
||||
private static final Map<Integer, Long> LAST_CALL = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
Habbo habbo = this.client.getHabbo();
|
||||
|
||||
// 1. Permission check
|
||||
if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Rate-limit per admin
|
||||
int adminId = habbo.getHabboInfo().getId();
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = LAST_CALL.get(adminId);
|
||||
if (last != null && (now - last) < RATE_LIMIT_MS) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Too many requests"));
|
||||
return;
|
||||
}
|
||||
LAST_CALL.put(adminId, now);
|
||||
|
||||
// 3. Read packet
|
||||
int itemId = this.packet.readInt();
|
||||
JsonObject json;
|
||||
try {
|
||||
json = JsonParser.parseString(this.packet.readString()).getAsJsonObject();
|
||||
} catch (Exception e) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid JSON data"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemId <= 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
String name = json.has("name") ? json.get("name").getAsString() : null;
|
||||
String description = json.has("description") ? json.get("description").getAsString() : null;
|
||||
|
||||
if (name == null && description == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No name or description provided"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Resolve classname from item_id
|
||||
String classname = classnameForItem(itemId);
|
||||
if (classname == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Write + reindex + broadcast under the shared lock
|
||||
FurnitureTextProvider provider =
|
||||
Emulator.getGameEnvironment().getFurnitureTextProvider();
|
||||
|
||||
if (provider == null || provider.getSource() == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture old values (before write) for the audit log
|
||||
String oldName = provider.getName(classname);
|
||||
// description is not indexed in the provider — treat as empty string for audit
|
||||
String oldDesc = "";
|
||||
|
||||
// FurnidataWriter.write() calls FurnitureTextProvider.sanitize() internally;
|
||||
// pass the raw values here and use them also for the audit log.
|
||||
String safeName = (name != null) ? name : "";
|
||||
String safeDesc = (description != null) ? description : "";
|
||||
|
||||
boolean written;
|
||||
List<FurnidataEntry> delta;
|
||||
|
||||
FurnidataLock.LOCK.lock();
|
||||
try {
|
||||
FurnidataWriter writer = new FurnidataWriter(
|
||||
provider.getSource(),
|
||||
provider.isSourceDirectory(),
|
||||
provider.getMaxBytes(),
|
||||
3 /* backupKeep */
|
||||
);
|
||||
written = writer.write(classname, safeName, safeDesc);
|
||||
if (!written) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata"));
|
||||
return;
|
||||
}
|
||||
|
||||
delta = provider.reindexFromSource();
|
||||
|
||||
if (!delta.isEmpty()) {
|
||||
int deltaCap = Integer.parseInt(
|
||||
Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||
FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
|
||||
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||
broadcastToAll(composer);
|
||||
}
|
||||
} finally {
|
||||
FurnidataLock.LOCK.unlock();
|
||||
}
|
||||
|
||||
// 5b. Auto-mirror the new display name into items_base.public_name (DB) so the
|
||||
// server-side fallback (Item.getFullName) and the editor's read-only
|
||||
// "Public Name" field stay in sync with the furnidata edit. Only when a
|
||||
// name was actually supplied (description-only edits must not blank it).
|
||||
// Kept outside FurnidataLock (independent DB write, like the audit log).
|
||||
if (name != null) {
|
||||
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement st = c.prepareStatement("UPDATE items_base SET public_name = ? WHERE id = ?")) {
|
||||
st.setString(1, FurnitureTextProvider.sanitize(safeName));
|
||||
st.setInt(2, itemId);
|
||||
st.executeUpdate();
|
||||
// Refresh the in-memory Item cache (Item.fullName) in place — no restart needed.
|
||||
Emulator.getGameEnvironment().getItemManager().loadItems();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to mirror furnidata name into items_base.public_name for item {}", itemId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Audit log (outside lock — DB write, not latency-sensitive)
|
||||
FurnidataAuditLog.record(
|
||||
adminId,
|
||||
classname,
|
||||
"edit",
|
||||
oldName != null ? oldName : "",
|
||||
FurnitureTextProvider.sanitize(safeName),
|
||||
oldDesc,
|
||||
FurnitureTextProvider.sanitize(safeDesc)
|
||||
);
|
||||
|
||||
// 7. Respond success
|
||||
this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata updated", itemId));
|
||||
LOGGER.info("FurniEditorUpdateFurnidataEvent: admin {} updated furnidata for classname '{}' (item {})",
|
||||
adminId, classname, itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the item_name (classname) from items_base for a given item id.
|
||||
* Kept static so FurniEditorRevertFurnidataEvent can reuse it.
|
||||
*
|
||||
* @return the classname string, or {@code null} if not found or on error.
|
||||
*/
|
||||
public static String classnameForItem(int itemId) {
|
||||
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement st = c.prepareStatement("SELECT item_name FROM items_base WHERE id = ?")) {
|
||||
st.setInt(1, itemId);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
if (rs.next()) return rs.getString("item_name");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("classnameForItem: failed to query items_base for id {}", itemId, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void broadcastToAll(FurnitureDataReloadComposer composer) {
|
||||
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||
if (habbo.getClient() != null) {
|
||||
habbo.getClient().sendResponse(composer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class FurnidataAuditLog {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataAuditLog.class);
|
||||
private FurnidataAuditLog() {}
|
||||
|
||||
public static void record(int userId, String classname, String action,
|
||||
String oldName, String newName, String oldDesc, String newDesc) {
|
||||
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement st = c.prepareStatement(
|
||||
"INSERT INTO furnidata_edit_log (user_id, classname, action, old_name, new_name, old_description, new_description, timestamp) " +
|
||||
"VALUES (?,?,?,?,?,?,?,?)")) {
|
||||
st.setInt(1, userId);
|
||||
st.setString(2, classname);
|
||||
st.setString(3, action);
|
||||
st.setString(4, oldName == null ? "" : oldName);
|
||||
st.setString(5, newName == null ? "" : newName);
|
||||
st.setString(6, oldDesc == null ? "" : oldDesc);
|
||||
st.setString(7, newDesc == null ? "" : newDesc);
|
||||
st.setInt(8, Emulator.getIntUnixTimestamp());
|
||||
st.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to write furnidata_edit_log", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -29,6 +29,11 @@ public class GuildDeclineMembershipEvent extends MessageHandler {
|
||||
if (guild != null) {
|
||||
GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, this.client.getHabbo());
|
||||
if (userId == this.client.getHabbo().getHabboInfo().getId() || guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || (member != null && (member.getRank().equals(GuildRank.ADMIN) || member.getRank().equals(GuildRank.OWNER))) || this.client.getHabbo().hasPermission(Permission.ACC_GUILD_ADMIN)) {
|
||||
GuildMember target = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, userId);
|
||||
if (target == null || target.getRank().type != GuildRank.REQUESTED.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
guild.decreaseRequestCount();
|
||||
Emulator.getGameEnvironment().getGuildManager().removeMember(guild, userId);
|
||||
this.client.sendResponse(new GuildMembersComposer(guild, Emulator.getGameEnvironment().getGuildManager().getGuildMembers(guild, 0, 0, ""), this.client.getHabbo(), 0, 0, "", true, Emulator.getGameEnvironment().getGuildManager().getGuildMembersCount(guild, 0, 0, "")));
|
||||
|
||||
+74
-69
@@ -30,20 +30,69 @@ public class RequestGuildBuyEvent extends MessageHandler {
|
||||
final String name = Emulator.getGameEnvironment().getWordFilter().filter(this.packet.readString(), this.client.getHabbo());
|
||||
final String description = Emulator.getGameEnvironment().getWordFilter().filter(this.packet.readString(), this.client.getHabbo());
|
||||
|
||||
if(name.length() > 29){
|
||||
if (name.length() == 0 || name.length() > 29) {
|
||||
this.client.sendResponse(new GuildEditFailComposer(GuildEditFailComposer.INVALID_GUILD_NAME));
|
||||
return;
|
||||
}
|
||||
if(description.length() > 254){
|
||||
if (description.length() > 254) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (Emulator.getConfig().getBoolean("catalog.guild.hc_required", true) && !this.client.getHabbo().getHabboStats().hasActiveClub()) {
|
||||
this.client.sendResponse(new GuildEditFailComposer(GuildEditFailComposer.HC_REQUIRED));
|
||||
return;
|
||||
}
|
||||
|
||||
int roomId = this.packet.readInt();
|
||||
|
||||
Room r = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId);
|
||||
|
||||
if (r == null) {
|
||||
this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.hasGuild() || r.getGuildId() != 0) {
|
||||
this.client.sendResponse(new GuildEditFailComposer(GuildEditFailComposer.ROOM_ALREADY_IN_USE));
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.getOwnerId() != this.client.getHabbo().getHabboInfo().getId()) {
|
||||
String message = Emulator.getTexts().getValue("scripter.warning.guild.buy.owner").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername()).replace("%roomname%", r.getName().replace("%owner%", r.getOwnerName()));
|
||||
ScripterManager.scripterDetected(this.client, message);
|
||||
LOGGER.info(message);
|
||||
return;
|
||||
}
|
||||
|
||||
int colorOne = this.packet.readInt();
|
||||
int colorTwo = this.packet.readInt();
|
||||
|
||||
int count = this.packet.readInt();
|
||||
|
||||
StringBuilder badge = new StringBuilder();
|
||||
|
||||
byte base = 1;
|
||||
|
||||
while (base < count) {
|
||||
int id = this.packet.readInt();
|
||||
int color = this.packet.readInt();
|
||||
int pos = this.packet.readInt();
|
||||
|
||||
if (base == 1) {
|
||||
badge.append("b");
|
||||
} else {
|
||||
badge.append("s");
|
||||
}
|
||||
|
||||
badge.append(id < 100 ? "0" : "").append(id < 10 ? "0" : "").append(id).append(color < 10 ? "0" : "").append(color).append(pos);
|
||||
|
||||
base += 3;
|
||||
}
|
||||
|
||||
// Only charge the player once every step has been validated. Previously the
|
||||
// credits were deducted before the room was checked, so a purchase that
|
||||
// failed afterwards (missing room, room already used by a guild, not the
|
||||
// owner) still took the credits without ever creating the group.
|
||||
if (!this.client.getHabbo().hasPermission(Permission.ACC_INFINITE_CREDITS)) {
|
||||
int guildPrice = Emulator.getConfig().getInt("catalog.guild.price");
|
||||
if (this.client.getHabbo().getHabboInfo().getCredits() >= guildPrice) {
|
||||
@@ -54,78 +103,34 @@ public class RequestGuildBuyEvent extends MessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
int roomId = this.packet.readInt();
|
||||
Guild guild = Emulator.getGameEnvironment().getGuildManager().createGuild(this.client.getHabbo(), roomId, r.getName(), name, description, badge.toString(), colorOne, colorTwo);
|
||||
|
||||
Room r = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId);
|
||||
r.setGuild(guild.getId());
|
||||
r.removeAllRights();
|
||||
r.setNeedsUpdate(true);
|
||||
|
||||
if (r != null) {
|
||||
if (r.hasGuild()) {
|
||||
this.client.sendResponse(new GuildEditFailComposer(GuildEditFailComposer.ROOM_ALREADY_IN_USE));
|
||||
return;
|
||||
Emulator.getGameEnvironment().getGuildManager().addGuild(guild);
|
||||
|
||||
if (Emulator.getConfig().getBoolean("imager.internal.enabled")) {
|
||||
Emulator.getBadgeImager().generate(guild);
|
||||
}
|
||||
|
||||
this.client.sendResponse(new PurchaseOKComposer());
|
||||
this.client.sendResponse(new GuildBoughtComposer(guild));
|
||||
r.refreshGuild(guild);
|
||||
|
||||
for (Habbo habbo : r.getHabbos()) {
|
||||
if (habbo.getClient() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (r.getOwnerId() == this.client.getHabbo().getHabboInfo().getId()) {
|
||||
if (r.getGuildId() == 0) {
|
||||
int colorOne = this.packet.readInt();
|
||||
int colorTwo = this.packet.readInt();
|
||||
habbo.getClient().sendResponse(new GuildInfoComposer(guild, habbo.getClient(), false, null));
|
||||
|
||||
int count = this.packet.readInt();
|
||||
|
||||
StringBuilder badge = new StringBuilder();
|
||||
|
||||
byte base = 1;
|
||||
|
||||
while (base < count) {
|
||||
int id = this.packet.readInt();
|
||||
int color = this.packet.readInt();
|
||||
int pos = this.packet.readInt();
|
||||
|
||||
if (base == 1) {
|
||||
badge.append("b");
|
||||
} else {
|
||||
badge.append("s");
|
||||
}
|
||||
|
||||
badge.append(id < 100 ? "0" : "").append(id < 10 ? "0" : "").append(id).append(color < 10 ? "0" : "").append(color).append(pos);
|
||||
|
||||
base += 3;
|
||||
}
|
||||
|
||||
Guild guild = Emulator.getGameEnvironment().getGuildManager().createGuild(this.client.getHabbo(), roomId, r.getName(), name, description, badge.toString(), colorOne, colorTwo);
|
||||
|
||||
r.setGuild(guild.getId());
|
||||
r.removeAllRights();
|
||||
r.setNeedsUpdate(true);
|
||||
|
||||
Emulator.getGameEnvironment().getGuildManager().addGuild(guild);
|
||||
|
||||
if (Emulator.getConfig().getBoolean("imager.internal.enabled")) {
|
||||
Emulator.getBadgeImager().generate(guild);
|
||||
}
|
||||
|
||||
this.client.sendResponse(new PurchaseOKComposer());
|
||||
this.client.sendResponse(new GuildBoughtComposer(guild));
|
||||
r.refreshGuild(guild);
|
||||
|
||||
for (Habbo habbo : r.getHabbos()) {
|
||||
if (habbo.getClient() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
habbo.getClient().sendResponse(new GuildInfoComposer(guild, habbo.getClient(), false, null));
|
||||
|
||||
if (habbo.getHabboInfo().getId() != this.client.getHabbo().getHabboInfo().getId()) {
|
||||
habbo.getClient().sendResponse(new RoomDataComposer(r, habbo, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
Emulator.getPluginManager().fireEvent(new GuildPurchasedEvent(guild, this.client.getHabbo()));
|
||||
}
|
||||
} else {
|
||||
String message = Emulator.getTexts().getValue("scripter.warning.guild.buy.owner").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername()).replace("%roomname%", r.getName().replace("%owner%", r.getOwnerName()));
|
||||
ScripterManager.scripterDetected(this.client, message);
|
||||
LOGGER.info(message);
|
||||
if (habbo.getHabboInfo().getId() != this.client.getHabbo().getHabboInfo().getId()) {
|
||||
habbo.getClient().sendResponse(new RoomDataComposer(r, habbo, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
Emulator.getPluginManager().fireEvent(new GuildPurchasedEvent(guild, this.client.getHabbo()));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -2,7 +2,11 @@ package com.eu.habbo.messages.incoming.guilds.forums;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.guilds.Guild;
|
||||
import com.eu.habbo.habbohotel.guilds.GuildMember;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
|
||||
import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer;
|
||||
|
||||
public class GuildForumDataEvent extends MessageHandler {
|
||||
@@ -20,10 +24,18 @@ public class GuildForumDataEvent extends MessageHandler {
|
||||
if (guild == null) return;
|
||||
if (!guild.hasForum()) return;
|
||||
|
||||
GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, this.client.getHabbo().getHabboInfo().getId());
|
||||
boolean staff = this.client.getHabbo().hasPermission(Permission.ACC_MODTOOL_TICKET_Q);
|
||||
|
||||
if (!guild.canHabboReadForum(this.client.getHabbo().getHabboInfo().getId(), member, staff)) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_ACCESS_DENIED.key).compose());
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.sendResponse(new GuildForumDataComposer(guild, this.client.getHabbo()));
|
||||
|
||||
if (!Emulator.getGameEnvironment().getGuildManager().hasViewedForum(this.client.getHabbo().getHabboInfo().getId(), guildId)) {
|
||||
Emulator.getGameEnvironment().getGuildManager().addView(this.client.getHabbo().getHabboInfo().getId(), guildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -2,7 +2,11 @@ package com.eu.habbo.messages.incoming.guilds.forums;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.guilds.Guild;
|
||||
import com.eu.habbo.habbohotel.guilds.GuildMember;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
|
||||
import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer;
|
||||
import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumThreadsComposer;
|
||||
import com.eu.habbo.messages.outgoing.handshake.ConnectionErrorComposer;
|
||||
@@ -24,8 +28,15 @@ public class GuildForumThreadsEvent extends MessageHandler {
|
||||
this.client.sendResponse(new ConnectionErrorComposer(404));
|
||||
return;
|
||||
}
|
||||
GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, this.client.getHabbo().getHabboInfo().getId());
|
||||
boolean staff = this.client.getHabbo().hasPermission(Permission.ACC_MODTOOL_TICKET_Q);
|
||||
|
||||
if (!guild.canHabboReadForum(this.client.getHabbo().getHabboInfo().getId(), member, staff)) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_ACCESS_DENIED.key).compose());
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.sendResponse(new GuildForumDataComposer(guild, this.client.getHabbo()));
|
||||
this.client.sendResponse(new GuildForumThreadsComposer(guild, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -38,7 +38,6 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify thread belongs to the requested guild
|
||||
if (thread.getGuildId() != guildId) {
|
||||
this.client.sendResponse(new ConnectionErrorComposer(403));
|
||||
return;
|
||||
@@ -47,6 +46,11 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler {
|
||||
GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, this.client.getHabbo().getHabboInfo().getId());
|
||||
boolean isGuildAdministrator = (guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || (member != null && member.getRank().equals(GuildRank.ADMIN)));
|
||||
|
||||
if (!guild.canHabboReadForum(this.client.getHabbo().getHabboInfo().getId(), member, hasStaffPermissions)) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_ACCESS_DENIED.key).compose());
|
||||
return;
|
||||
}
|
||||
|
||||
if (thread.getState() != ForumThreadState.HIDDEN_BY_GUILD_ADMIN || hasStaffPermissions || isGuildAdministrator) {
|
||||
this.client.sendResponse(new GuildForumCommentsComposer(guildId, threadId, index, thread.getComments(limit, index)));
|
||||
this.client.sendResponse(new GuildForumDataComposer(guild, this.client.getHabbo()));
|
||||
|
||||
+5
@@ -6,6 +6,11 @@ import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.UserPrefixesComposer;
|
||||
|
||||
public class DeletePrefixEvent extends MessageHandler {
|
||||
@Override
|
||||
public int getRatelimit() {
|
||||
return 500;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
int prefixId = this.packet.readInt();
|
||||
|
||||
+132
-58
@@ -1,15 +1,20 @@
|
||||
package com.eu.habbo.messages.incoming.inventory.prefixes;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.modtool.WordFilterWord;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.habbohotel.users.UserPrefix;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
|
||||
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
|
||||
import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.ActivePrefixUpdatedComposer;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.CustomPrefixPurchaseFailedComposer;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.PrefixReceivedComposer;
|
||||
import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDataComposer;
|
||||
import com.eu.habbo.messages.outgoing.users.UserCreditsComposer;
|
||||
import com.eu.habbo.messages.outgoing.users.UserCurrencyComposer;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -17,10 +22,18 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class PurchasePrefixEvent extends MessageHandler {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PurchasePrefixEvent.class);
|
||||
private static final String[] ALLOWED_FONTS = { "", "pixel", "cherry", "vampiro" };
|
||||
private static final String[] ALLOWED_EFFECTS = {
|
||||
"", "glow", "shadow", "italic", "outline", "underline", "pulse", "bounce", "wave", "shake",
|
||||
"discord-neon", "cartoon", "toon", "pop", "bold-glow", "rainbow", "frost", "gold", "glitch",
|
||||
"fire", "matrix", "sparkle"
|
||||
};
|
||||
private static final int MAX_ICON_LENGTH = 16;
|
||||
|
||||
@Override
|
||||
public int getRatelimit() {
|
||||
@@ -39,81 +52,101 @@ public class PurchasePrefixEvent extends MessageHandler {
|
||||
|
||||
if (habbo == null) return;
|
||||
|
||||
// Load settings
|
||||
int maxLength = getSettingInt("max_length", 15);
|
||||
int minRank = getSettingInt("min_rank_to_buy", 1);
|
||||
int priceCredits = getSettingInt("price_credits", 5);
|
||||
int pricePoints = getSettingInt("price_points", 0);
|
||||
int pointsType = getSettingInt("points_type", 0);
|
||||
int fontPriceCredits = getSettingInt("font_price_credits", 10);
|
||||
int fontPricePoints = getSettingInt("font_price_points", 0);
|
||||
int fontPointsType = getSettingInt("font_points_type", pointsType);
|
||||
Map<String, Integer> settings = loadSettings();
|
||||
int maxLength = setting(settings, "max_length", 15);
|
||||
int minRank = setting(settings, "min_rank_to_buy", 1);
|
||||
int priceCredits = setting(settings, "price_credits", 5);
|
||||
int pricePoints = setting(settings, "price_points", 0);
|
||||
int pointsType = setting(settings, "points_type", 0);
|
||||
int fontPriceCredits = setting(settings, "font_price_credits", 10);
|
||||
int fontPricePoints = setting(settings, "font_price_points", 0);
|
||||
int fontPointsType = setting(settings, "font_points_type", pointsType);
|
||||
int maxPrefixes = setting(settings, "max_prefixes", 60);
|
||||
|
||||
// Validate text
|
||||
text = text.trim();
|
||||
|
||||
if (text.isEmpty() || text.length() > maxLength) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Prefix text is invalid or too long (max " + maxLength + " characters)."));
|
||||
if (maxPrefixes > 0 && habbo.getInventory().getPrefixesComponent().getPrefixes().size() >= maxPrefixes) {
|
||||
this.fail(habbo, "You already own the maximum number of prefixes (" + maxPrefixes + ").");
|
||||
return;
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
|
||||
if (text.isEmpty() || text.length() > maxLength) {
|
||||
this.fail(habbo, "Prefix text is invalid or too long (max " + maxLength + " characters).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (containsControlChars(text)) {
|
||||
this.fail(habbo, "Prefix text contains invalid characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (containsFilteredWord(text)) {
|
||||
this.fail(habbo, "This prefix contains a blocked word.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate color (single hex or comma-separated multi hex for per-letter colors)
|
||||
String[] colorParts = color.split(",");
|
||||
|
||||
if (colorParts.length > text.length()) {
|
||||
this.fail(habbo, "Invalid color format.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (String part : colorParts) {
|
||||
if (!part.matches("^#[0-9A-Fa-f]{6}$")) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Invalid color format."));
|
||||
this.fail(habbo, "Invalid color format.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check rank
|
||||
if (habbo.getHabboInfo().getRank().getId() < minRank) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Your rank is too low to purchase prefixes."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check blacklist
|
||||
if (isBlacklisted(text)) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "This prefix contains a blocked word."));
|
||||
this.fail(habbo, "Your rank is too low to purchase prefixes.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (icon == null) icon = "";
|
||||
icon = icon.trim();
|
||||
|
||||
if (!isValidIcon(icon)) {
|
||||
this.fail(habbo, "Invalid prefix icon.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect == null) effect = "";
|
||||
effect = effect.trim();
|
||||
effect = effect.trim().toLowerCase();
|
||||
|
||||
if (!isAllowedEffect(effect)) {
|
||||
this.fail(habbo, "Invalid prefix effect.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (font == null) font = "";
|
||||
font = font.trim().toLowerCase();
|
||||
|
||||
if (!isAllowedFont(font)) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Invalid font format."));
|
||||
this.fail(habbo, "Invalid font format.");
|
||||
return;
|
||||
}
|
||||
|
||||
int totalPriceCredits = priceCredits + (!font.isEmpty() ? fontPriceCredits : 0);
|
||||
|
||||
// Check credits
|
||||
if (totalPriceCredits > 0 && habbo.getHabboInfo().getCredits() < totalPriceCredits) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough credits."));
|
||||
this.fail(habbo, "Not enough credits.");
|
||||
return;
|
||||
}
|
||||
|
||||
int totalPricePointsSameType = pricePoints + ((fontPricePoints > 0 && fontPointsType == pointsType && !font.isEmpty()) ? fontPricePoints : 0);
|
||||
|
||||
// Check points
|
||||
if (totalPricePointsSameType > 0 && habbo.getHabboInfo().getCurrencyAmount(pointsType) < totalPricePointsSameType) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough points."));
|
||||
this.fail(habbo, "Not enough points.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!font.isEmpty() && fontPricePoints > 0 && fontPointsType != pointsType && habbo.getHabboInfo().getCurrencyAmount(fontPointsType) < fontPricePoints) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough points."));
|
||||
this.fail(habbo, "Not enough points.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduct currency
|
||||
if (totalPriceCredits > 0) {
|
||||
habbo.getHabboInfo().addCredits(-totalPriceCredits);
|
||||
this.client.sendResponse(new UserCreditsComposer(habbo));
|
||||
@@ -129,47 +162,57 @@ public class PurchasePrefixEvent extends MessageHandler {
|
||||
this.client.sendResponse(new UserCurrencyComposer(habbo));
|
||||
}
|
||||
|
||||
// Create prefix
|
||||
int storedPoints = totalPricePointsSameType;
|
||||
int storedPointsType = (storedPoints > 0) ? pointsType : ((!font.isEmpty() && fontPricePoints > 0) ? fontPointsType : pointsType);
|
||||
|
||||
UserPrefix prefix = new UserPrefix(habbo.getHabboInfo().getId(), text, color, icon, effect, font, 0, text, storedPoints, storedPointsType, true);
|
||||
prefix.run(); // Insert into DB synchronously to get the ID
|
||||
habbo.getInventory().getPrefixesComponent().addPrefix(prefix);
|
||||
|
||||
habbo.getInventory().getPrefixesComponent().setActive(prefix.getId());
|
||||
this.client.sendResponse(new PrefixReceivedComposer(prefix));
|
||||
this.client.sendResponse(new ActivePrefixUpdatedComposer(prefix));
|
||||
this.client.sendResponse(new UserNickIconsComposer(habbo));
|
||||
}
|
||||
|
||||
private int getSettingInt(String key, int defaultValue) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT `value` FROM custom_prefix_settings WHERE key_name = ?")) {
|
||||
statement.setString(1, key);
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
if (set.next()) {
|
||||
return Integer.parseInt(set.getString("value"));
|
||||
}
|
||||
}
|
||||
} catch (SQLException | NumberFormatException e) {
|
||||
LOGGER.error("Error reading prefix setting: " + key, e);
|
||||
if (habbo.getHabboInfo().getCurrentRoom() != null) {
|
||||
habbo.getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(habbo).compose());
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private boolean isBlacklisted(String text) {
|
||||
String lowerText = text.toLowerCase();
|
||||
private void fail(Habbo habbo, String message) {
|
||||
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, message));
|
||||
this.client.sendResponse(new CustomPrefixPurchaseFailedComposer(message));
|
||||
}
|
||||
|
||||
private Map<String, Integer> loadSettings() {
|
||||
Map<String, Integer> settings = new HashMap<>();
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT word FROM custom_prefix_blacklist")) {
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
while (set.next()) {
|
||||
if (lowerText.contains(set.getString("word").toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT key_name, `value` FROM custom_prefix_settings");
|
||||
ResultSet set = statement.executeQuery()) {
|
||||
while (set.next()) {
|
||||
try {
|
||||
settings.put(set.getString("key_name"), Integer.parseInt(set.getString("value")));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error checking prefix blacklist", e);
|
||||
LOGGER.error("Error reading prefix settings", e);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private int setting(Map<String, Integer> settings, String key, int defaultValue) {
|
||||
Integer value = settings.get(key);
|
||||
return value != null ? value : defaultValue;
|
||||
}
|
||||
|
||||
private boolean containsFilteredWord(String text) {
|
||||
if (text == null || text.isEmpty()) return false;
|
||||
|
||||
for (WordFilterWord word : Emulator.getGameEnvironment().getWordFilter().getWords()) {
|
||||
if (word.key != null && !word.key.isEmpty() && StringUtils.containsIgnoreCase(text, word.key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -182,4 +225,35 @@ public class PurchasePrefixEvent extends MessageHandler {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isAllowedEffect(String effect) {
|
||||
for (String allowedEffect : ALLOWED_EFFECTS) {
|
||||
if (allowedEffect.equals(effect)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isValidIcon(String icon) {
|
||||
if (icon.isEmpty()) return true;
|
||||
if (icon.length() > MAX_ICON_LENGTH) return false;
|
||||
|
||||
for (int i = 0; i < icon.length(); i++) {
|
||||
char c = icon.charAt(i);
|
||||
if (c < 0x20 || c == 0x7F || c == '<' || c == '>') return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean containsControlChars(String text) {
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (c < 0x20 || c == 0x7F) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -4,6 +4,11 @@ import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.UserPrefixesComposer;
|
||||
|
||||
public class RequestUserPrefixesEvent extends MessageHandler {
|
||||
@Override
|
||||
public int getRatelimit() {
|
||||
return 500;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
this.client.sendResponse(new UserPrefixesComposer(this.client.getHabbo()));
|
||||
|
||||
+6
-1
@@ -2,11 +2,16 @@ package com.eu.habbo.messages.incoming.inventory.prefixes;
|
||||
|
||||
import com.eu.habbo.habbohotel.users.UserPrefix;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.ActivePrefixUpdatedComposer;
|
||||
import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
|
||||
import com.eu.habbo.messages.outgoing.inventory.prefixes.ActivePrefixUpdatedComposer;
|
||||
import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDataComposer;
|
||||
|
||||
public class SetActivePrefixEvent extends MessageHandler {
|
||||
@Override
|
||||
public int getRatelimit() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
int prefixId = this.packet.readInt();
|
||||
|
||||
+6
-1
@@ -7,6 +7,11 @@ import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
|
||||
import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDataComposer;
|
||||
|
||||
public class SetDisplayOrderEvent extends MessageHandler {
|
||||
@Override
|
||||
public int getRatelimit() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
Habbo habbo = this.client.getHabbo();
|
||||
@@ -23,4 +28,4 @@ public class SetDisplayOrderEvent extends MessageHandler {
|
||||
habbo.getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(habbo).compose());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.eu.habbo.messages.incoming.mentions;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.mentions.MentionManager;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
|
||||
public class DeleteMentionEvent extends MessageHandler {
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (this.client == null || this.client.getHabbo() == null) return;
|
||||
|
||||
int userId = this.client.getHabbo().getHabboInfo().getId();
|
||||
int mentionId = this.packet.readInt();
|
||||
|
||||
if (mentionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
MentionManager manager = Emulator.getGameEnvironment().getMentionManager();
|
||||
|
||||
if (!manager.tryAcquireDelete(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.delete(userId, mentionId);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.eu.habbo.messages.incoming.mentions;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.mentions.MentionManager;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
|
||||
public class MarkMentionsReadEvent extends MessageHandler {
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (this.client == null || this.client.getHabbo() == null) return;
|
||||
|
||||
int userId = this.client.getHabbo().getHabboInfo().getId();
|
||||
int mode = this.packet.readInt();
|
||||
int mentionId = this.packet.readInt();
|
||||
|
||||
// Only mode 0 (mark-all) and mode 1 (mark-single) are valid. Reject
|
||||
// anything else so a crafted packet can't fall into the mark-all branch
|
||||
// by accident.
|
||||
if (mode != 0 && mode != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == 1 && mentionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
MentionManager manager = Emulator.getGameEnvironment().getMentionManager();
|
||||
|
||||
if (!manager.tryAcquireMarkRead(userId, mode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.markRead(userId, mode, mentionId);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.eu.habbo.messages.incoming.mentions;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.mentions.HabboMention;
|
||||
import com.eu.habbo.habbohotel.mentions.MentionManager;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.mentions.MentionsListComposer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RequestMentionsEvent extends MessageHandler {
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
if (this.client == null || this.client.getHabbo() == null) return;
|
||||
|
||||
int userId = this.client.getHabbo().getHabboInfo().getId();
|
||||
|
||||
MentionManager manager = Emulator.getGameEnvironment().getMentionManager();
|
||||
|
||||
if (!manager.tryAcquireRequestList(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int limit = Emulator.getConfig().getInt("mentions.store.limit", 50);
|
||||
|
||||
List<HabboMention> mentions = manager.getMentions(userId, limit);
|
||||
this.client.sendResponse(new MentionsListComposer(mentions));
|
||||
}
|
||||
}
|
||||
+3
@@ -34,6 +34,9 @@ public class RoomUserShoutEvent extends MessageHandler {
|
||||
if (RoomChatMessage.SAVE_ROOM_CHATS) {
|
||||
Emulator.getThreading().run(message);
|
||||
}
|
||||
|
||||
Emulator.getGameEnvironment().getMentionManager()
|
||||
.process(this.client.getHabbo(), this.client.getHabbo().getHabboInfo().getCurrentRoom(), message.getMessage(), RoomChatType.SHOUT);
|
||||
}
|
||||
} else {
|
||||
String reportMessage = Emulator.getTexts().getValue("scripter.warning.chat.length").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername()).replace("%length%", message.getMessage().length() + "");
|
||||
|
||||
+3
@@ -36,6 +36,9 @@ public class RoomUserTalkEvent extends MessageHandler {
|
||||
if (RoomChatMessage.SAVE_ROOM_CHATS) {
|
||||
Emulator.getThreading().run(message);
|
||||
}
|
||||
|
||||
Emulator.getGameEnvironment().getMentionManager()
|
||||
.process(this.client.getHabbo(), room, message.getMessage(), RoomChatType.TALK);
|
||||
}
|
||||
} else {
|
||||
String reportMessage = Emulator.getTexts().getValue("scripter.warning.chat.length").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername()).replace("%length%", message.getMessage().length() + "");
|
||||
|
||||
@@ -570,15 +570,20 @@ public class Outgoing {
|
||||
public static final int FurniEditorDetailComposer = 10041;
|
||||
public static final int FurniEditorInteractionsComposer = 10043;
|
||||
public static final int FurniEditorResultComposer = 10044;
|
||||
public static final int FurnitureDataReloadComposer = 10047; // CUSTOM
|
||||
public static final int FurniEditorImportTextResultComposer = 10049; // CUSTOM
|
||||
|
||||
// Catalog Admin
|
||||
public static final int CatalogAdminResultComposer = 10059;
|
||||
public static final int CatalogAdminOfferDetailsComposer = 10062;
|
||||
public static final int CatalogAdminPageDetailsComposer = 10063;
|
||||
|
||||
// Custom Prefixes
|
||||
public static final int UserPrefixesComposer = 7001;
|
||||
public static final int PrefixReceivedComposer = 7002;
|
||||
public static final int ActivePrefixUpdatedComposer = 7003;
|
||||
public static final int UserNickIconsComposer = 7004;
|
||||
public static final int CustomPrefixPurchaseFailedComposer = 7005;
|
||||
public static final int AvailableCommandsComposer = 4050;
|
||||
|
||||
// YouTube Room Broadcast
|
||||
@@ -602,5 +607,7 @@ public class Outgoing {
|
||||
public static final int WheelAdminPrizesComposer = 9404;
|
||||
public static final int SoundboardSettingsComposer = 9405;
|
||||
public static final int SoundboardPlayComposer = 9406;
|
||||
public static final int MentionReceivedComposer = 4801;
|
||||
public static final int MentionsListComposer = 4802;
|
||||
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.eu.habbo.messages.outgoing.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class CatalogAdminOfferDetailsComposer extends MessageComposer {
|
||||
private final int offerId;
|
||||
private final int offerIdGroup;
|
||||
private final int limitedStack;
|
||||
private final int orderNumber;
|
||||
|
||||
public CatalogAdminOfferDetailsComposer(int offerId, int offerIdGroup, int limitedStack, int orderNumber) {
|
||||
this.offerId = offerId;
|
||||
this.offerIdGroup = offerIdGroup;
|
||||
this.limitedStack = limitedStack;
|
||||
this.orderNumber = orderNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.CatalogAdminOfferDetailsComposer);
|
||||
this.response.appendInt(this.offerId);
|
||||
this.response.appendInt(this.offerIdGroup);
|
||||
this.response.appendInt(this.limitedStack);
|
||||
this.response.appendInt(this.orderNumber);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.eu.habbo.messages.outgoing.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPage;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class CatalogAdminPageDetailsComposer extends MessageComposer {
|
||||
private final CatalogPage page;
|
||||
|
||||
public CatalogAdminPageDetailsComposer(CatalogPage page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.CatalogAdminPageDetailsComposer);
|
||||
this.response.appendInt(this.page.getId());
|
||||
this.response.appendString(this.page.getCaption());
|
||||
this.response.appendString(this.page.getPageName());
|
||||
this.response.appendInt(this.page.getRank());
|
||||
this.response.appendInt(this.page.getOrderNum());
|
||||
this.response.appendBoolean(this.page.isVisible());
|
||||
this.response.appendBoolean(this.page.isEnabled());
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class FriendsComposer extends MessageComposer {
|
||||
this.response.appendInt(row.getGender().equals(HabboGender.M) ? 0 : 1);
|
||||
this.response.appendBoolean(row.getOnline() == 1);
|
||||
this.response.appendBoolean(row.inRoom()); //IN ROOM
|
||||
this.response.appendString(row.getOnline() == 1 ? row.getLook() : "");
|
||||
this.response.appendString(row.getLook()); // send look for offline friends too (loaded from DB)
|
||||
this.response.appendInt(row.getCategoryId()); //Friends category
|
||||
this.response.appendString(row.getMotto());
|
||||
this.response.appendString(""); //Last seen as DATETIMESTRING
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.eu.habbo.messages.outgoing.furnieditor;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
/**
|
||||
* Outgoing 10049 — result of an "import texts from Habbo" request.
|
||||
* Carries the official furnidata name/description for a classname (or found=false).
|
||||
*/
|
||||
public class FurniEditorImportTextResultComposer extends MessageComposer {
|
||||
private final boolean found;
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final String classname;
|
||||
|
||||
public FurniEditorImportTextResultComposer(boolean found, String name, String description, String classname) {
|
||||
this.found = found;
|
||||
this.name = name == null ? "" : name;
|
||||
this.description = description == null ? "" : description;
|
||||
this.classname = classname == null ? "" : classname;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.FurniEditorImportTextResultComposer);
|
||||
this.response.appendBoolean(this.found);
|
||||
this.response.appendString(this.name);
|
||||
this.response.appendString(this.description);
|
||||
this.response.appendString(this.classname);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.eu.habbo.messages.outgoing.furniture;
|
||||
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||
import com.eu.habbo.habbohotel.items.FurnitureType;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class FurnitureDataReloadComposer extends MessageComposer {
|
||||
|
||||
public static final int MODE_DELTA = 0;
|
||||
public static final int MODE_RELOAD_HINT = 1;
|
||||
|
||||
private final int mode;
|
||||
private final List<FurnidataEntry> entries;
|
||||
|
||||
public FurnitureDataReloadComposer(int mode, List<FurnidataEntry> entries) {
|
||||
this.mode = mode;
|
||||
this.entries = entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.FurnitureDataReloadComposer);
|
||||
this.response.appendInt(this.mode);
|
||||
|
||||
if (this.mode == MODE_DELTA) {
|
||||
this.response.appendInt(this.entries.size());
|
||||
for (FurnidataEntry e : this.entries) {
|
||||
this.response.appendString(e.type() == FurnitureType.FLOOR ? "S" : "I");
|
||||
this.response.appendInt(e.id());
|
||||
this.response.appendString(e.classname());
|
||||
this.response.appendString(e.name());
|
||||
this.response.appendString(e.description());
|
||||
}
|
||||
}
|
||||
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eu.habbo.messages.outgoing.inventory.prefixes;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class CustomPrefixPurchaseFailedComposer extends MessageComposer {
|
||||
private final String message;
|
||||
|
||||
public CustomPrefixPurchaseFailedComposer(String message) {
|
||||
this.message = message != null ? message : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.CustomPrefixPurchaseFailedComposer);
|
||||
this.response.appendString(this.message);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.eu.habbo.messages.outgoing.mentions;
|
||||
|
||||
import com.eu.habbo.habbohotel.mentions.HabboMention;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class MentionReceivedComposer extends MessageComposer {
|
||||
private final HabboMention mention;
|
||||
|
||||
public MentionReceivedComposer(HabboMention mention) {
|
||||
this.mention = mention;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.MentionReceivedComposer);
|
||||
this.response.appendInt(this.mention.getId());
|
||||
this.response.appendInt(this.mention.getSenderUserId());
|
||||
this.response.appendString(this.mention.getSenderUsername());
|
||||
this.response.appendString(this.mention.getSenderFigure());
|
||||
this.response.appendInt(this.mention.getRoomId());
|
||||
this.response.appendString(this.mention.getRoomName());
|
||||
this.response.appendString(this.mention.getMessage());
|
||||
this.response.appendInt(this.mention.getMentionType());
|
||||
this.response.appendInt(this.mention.getTimestamp());
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.eu.habbo.messages.outgoing.mentions;
|
||||
|
||||
import com.eu.habbo.habbohotel.mentions.HabboMention;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class MentionsListComposer extends MessageComposer {
|
||||
private final List<HabboMention> mentions;
|
||||
|
||||
public MentionsListComposer(List<HabboMention> mentions) {
|
||||
this.mentions = mentions;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.MentionsListComposer);
|
||||
this.response.appendInt(this.mentions.size());
|
||||
|
||||
for (HabboMention mention : this.mentions) {
|
||||
this.response.appendInt(mention.getId());
|
||||
this.response.appendInt(mention.getSenderUserId());
|
||||
this.response.appendString(mention.getSenderUsername());
|
||||
this.response.appendString(mention.getSenderFigure());
|
||||
this.response.appendInt(mention.getRoomId());
|
||||
this.response.appendString(mention.getRoomName());
|
||||
this.response.appendString(mention.getMessage());
|
||||
this.response.appendInt(mention.getMentionType());
|
||||
this.response.appendInt(mention.getTimestamp());
|
||||
this.response.appendBoolean(mention.isRead());
|
||||
}
|
||||
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ public class WatchAndEarnRewardComposer extends MessageComposer {
|
||||
this.response.appendString(this.item.getType().code);
|
||||
this.response.appendInt(this.item.getId());
|
||||
this.response.appendString(this.item.getName());
|
||||
this.response.appendString(this.item.getFullName());
|
||||
this.response.appendString(this.item.getDisplayName());
|
||||
return this.response;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.eu.habbo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SmokeTest {
|
||||
@Test
|
||||
void harnessRuns() {
|
||||
assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnidataReaderTest {
|
||||
|
||||
private static final String SINGLE = """
|
||||
{
|
||||
// a comment
|
||||
"roomitemtypes": { "furnitype": [
|
||||
{ "id": 10, "classname": "chair_norja", "name": "Chair", "description": "Sit", "xdim": 1, "ydim": 1 },
|
||||
]},
|
||||
"wallitemtypes": { "furnitype": [
|
||||
{ "id": 20, "classname": "poster_5", "name": "Poster", "description": "Wall" }
|
||||
]}
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void parsesSingleFileFloorAndWall(@TempDir Path dir) throws Exception {
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
|
||||
List<FurnidataEntry> entries = new FurnidataReader(file, 64 * 1024 * 1024).read();
|
||||
|
||||
assertEquals(2, entries.size());
|
||||
FurnidataEntry floor = entries.stream().filter(e -> e.id() == 10).findFirst().orElseThrow();
|
||||
assertEquals("chair_norja", floor.classname());
|
||||
assertEquals(FurnitureType.FLOOR, floor.type());
|
||||
assertEquals("Chair", floor.name());
|
||||
FurnidataEntry wall = entries.stream().filter(e -> e.id() == 20).findFirst().orElseThrow();
|
||||
assertEquals(FurnitureType.WALL, wall.type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFileOverSizeCap(@TempDir Path dir) throws Exception {
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
List<FurnidataEntry> entries = new FurnidataReader(file, 8 /* bytes */).read();
|
||||
assertTrue(entries.isEmpty(), "oversized file must be refused, returning empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSourceReturnsEmptyNeverThrows(@TempDir Path dir) {
|
||||
Path missing = dir.resolve("does-not-exist.json");
|
||||
assertDoesNotThrow(() -> {
|
||||
assertTrue(new FurnidataReader(missing, 64 * 1024 * 1024).read().isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedManifestIsSkippedNeverThrows(@TempDir Path dir) throws Exception {
|
||||
Path base = dir.resolve("furnidata");
|
||||
Path core = base.resolve("core");
|
||||
Files.createDirectories(core);
|
||||
// A root manifest larger than the cap we pass in.
|
||||
Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] } // padding ".repeat(50));
|
||||
List<FurnidataEntry> entries = new FurnidataReader(base, 8 /* bytes */).read();
|
||||
assertTrue(entries.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitDirRejectsTraversalFiles(@TempDir Path dir) throws Exception {
|
||||
Path secret = dir.resolve("secret.json");
|
||||
Files.writeString(secret, "{ \"roomitemtypes\": { \"furnitype\": [ { \"id\": 99, \"classname\": \"x\", \"name\": \"LEAK\", \"description\": \"\" } ] } }");
|
||||
|
||||
Path base = dir.resolve("furnidata");
|
||||
Path core = base.resolve("core");
|
||||
Files.createDirectories(core);
|
||||
Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] }");
|
||||
Files.writeString(core.resolve("manifest.json"), "{ \"files\": [ \"../../secret.json\" ] }");
|
||||
|
||||
List<FurnidataEntry> entries = new FurnidataReader(base, 64 * 1024 * 1024).read();
|
||||
|
||||
assertTrue(entries.stream().noneMatch(e -> e.id() == 99),
|
||||
"traversal file outside the base dir must be ignored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnidataWriterTest {
|
||||
|
||||
private static final String SINGLE =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
// Tier data: core has the entry with "core name"; custom ALSO has it with "custom old name".
|
||||
// The writer must pick the custom (winning) tier and leave core untouched.
|
||||
private static final String CORE_DATA =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"core name\", \"description\": \"core desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
private static final String CUSTOM_DATA =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"custom old name\", \"description\": \"custom old desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
@Test
|
||||
void writesNameAndDescriptionByClassnameSingleFile() throws Exception {
|
||||
Path dir = Files.createTempDirectory("fd");
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("01_caterhead", "Cat Head", "A cat head");
|
||||
|
||||
assertTrue(ok);
|
||||
String after = Files.readString(file);
|
||||
assertTrue(after.contains("\"Cat Head\""));
|
||||
assertTrue(after.contains("\"A cat head\""));
|
||||
assertFalse(after.contains("old name"));
|
||||
// backup created
|
||||
assertTrue(Files.list(dir).anyMatch(p -> p.getFileName().toString().startsWith("FurnitureData.json.bak")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownClassname() throws Exception {
|
||||
Path dir = Files.createTempDirectory("fd");
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||
assertFalse(w.write("does_not_exist", "x", "y"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split-tier: classname present in both core and custom tiers.
|
||||
* The writer must update the winning (later) tier — custom — and leave core untouched.
|
||||
*/
|
||||
@Test
|
||||
void splitTierWritesWinningTierLeavesEarlierTierUntouched() throws Exception {
|
||||
Path base = Files.createTempDirectory("fd-split");
|
||||
|
||||
// Tier subdirectories
|
||||
Path coreDir = base.resolve("core");
|
||||
Path customDir = base.resolve("custom");
|
||||
Files.createDirectories(coreDir);
|
||||
Files.createDirectories(customDir);
|
||||
|
||||
// Top-level manifest: tiers in override order (core < custom)
|
||||
Files.writeString(base.resolve("manifest.json"),
|
||||
"{ \"tiers\": [ \"core\", \"custom\" ] }");
|
||||
|
||||
// Per-tier manifests listing the data file
|
||||
Files.writeString(coreDir.resolve("manifest.json"),
|
||||
"{ \"files\": [ \"furnidata.json\" ] }");
|
||||
Files.writeString(customDir.resolve("manifest.json"),
|
||||
"{ \"files\": [ \"furnidata.json\" ] }");
|
||||
|
||||
// Data files
|
||||
Path coreFile = coreDir.resolve("furnidata.json");
|
||||
Path customFile = customDir.resolve("furnidata.json");
|
||||
Files.writeString(coreFile, CORE_DATA);
|
||||
Files.writeString(customFile, CUSTOM_DATA);
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("split_chair", "New Name", "New desc");
|
||||
|
||||
assertTrue(ok, "write must succeed for classname present in split-tier layout");
|
||||
|
||||
// custom (winning tier) must be updated
|
||||
String customAfter = Files.readString(customFile);
|
||||
assertTrue(customAfter.contains("\"New Name\""), "winning tier must contain new name");
|
||||
assertTrue(customAfter.contains("\"New desc\""), "winning tier must contain new desc");
|
||||
assertFalse(customAfter.contains("custom old name"), "old name must be gone from winning tier");
|
||||
|
||||
// core (earlier tier) must be UNTOUCHED
|
||||
String coreAfter = Files.readString(coreFile);
|
||||
assertTrue(coreAfter.contains("core name"), "earlier tier must be left untouched");
|
||||
}
|
||||
|
||||
/**
|
||||
* Split-tier path-traversal guard: a manifest that lists "../escape" as a tier
|
||||
* must be rejected by safeResolve so the writer cannot reach files outside the base dir.
|
||||
*/
|
||||
@Test
|
||||
void splitTierRejectsTraversalTierInManifest() throws Exception {
|
||||
Path base = Files.createTempDirectory("fd-traversal");
|
||||
|
||||
// "Escape" directory sits OUTSIDE base
|
||||
Path escapeDir = base.getParent().resolve("escape_secret");
|
||||
Files.createDirectories(escapeDir);
|
||||
Files.writeString(escapeDir.resolve("manifest.json"),
|
||||
"{ \"files\": [ \"secret.json\" ] }");
|
||||
Files.writeString(escapeDir.resolve("secret.json"),
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 99, \"classname\": \"escape_chair\", \"name\": \"secret old\", \"description\": \"\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }");
|
||||
|
||||
// Top-level manifest references the escape dir via traversal
|
||||
Files.writeString(base.resolve("manifest.json"),
|
||||
"{ \"tiers\": [ \"../escape_secret\" ] }");
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("escape_chair", "Pwned", "desc");
|
||||
|
||||
assertFalse(ok, "classname reachable only via traversal path must not be found/written");
|
||||
|
||||
// The secret file must not have been touched
|
||||
String secretAfter = Files.readString(escapeDir.resolve("secret.json"));
|
||||
assertTrue(secretAfter.contains("secret old"), "traversal target must be untouched");
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnitureTextProviderDeltaTest {
|
||||
|
||||
@Test
|
||||
void firstReindexReturnsAllAsDelta() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||
assertEquals(1, delta.size());
|
||||
assertEquals("Chair", delta.get(0).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedReindexReturnsEmptyDelta() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
List<FurnidataEntry> first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"));
|
||||
p.reindex(first);
|
||||
List<FurnidataEntry> delta = p.reindex(first);
|
||||
assertTrue(delta.isEmpty(), "no change => empty delta");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedNameAppearsInDeltaWithSanitizedValue() {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||
p.reindex(List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "New %x%", "Sit")));
|
||||
assertEquals(1, delta.size());
|
||||
assertFalse(delta.get(0).name().contains("%"), "delta carries the sanitized name");
|
||||
assertEquals(1, delta.get(0).id());
|
||||
assertEquals(FurnitureType.FLOOR, delta.get(0).type());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnitureTextProviderTest {
|
||||
|
||||
private FurnitureTextProvider provider(boolean enabled, FurnidataEntry... entries) {
|
||||
FurnitureTextProvider p = new FurnitureTextProvider(enabled);
|
||||
p.reindex(List.of(entries));
|
||||
return p;
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesNameByClassname() {
|
||||
FurnitureTextProvider p = provider(true,
|
||||
new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||
assertEquals("Norja Chair", p.getName("chair_norja"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesBaseClassnameIgnoringColourVariantAndCase() {
|
||||
FurnitureTextProvider p = provider(true,
|
||||
new FurnidataEntry(1, "chair_norja*2", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||
assertEquals("Norja Chair", p.getName("CHAIR_NORJA"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenClassnameMissing() {
|
||||
FurnitureTextProvider p = provider(true);
|
||||
assertNull(p.getName("unknown_thing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenDisabled() {
|
||||
FurnitureTextProvider p = provider(false,
|
||||
new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||
assertNull(p.getName("chair_norja"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizesNameCapStripControlAndNeutralizesPercent() {
|
||||
String evil = "Bad\nName %limit% %user.name%".repeat(20);
|
||||
FurnitureTextProvider p = provider(true,
|
||||
new FurnidataEntry(1, "x", FurnitureType.FLOOR, evil, ""));
|
||||
String name = p.getName("x");
|
||||
assertEquals(256, name.length(), "input far exceeds the cap, so it must be exactly 256");
|
||||
assertFalse(name.chars().anyMatch(Character::isISOControl), "no control chars remain after sanitize");
|
||||
assertFalse(name.contains("%"), "ASCII percent neutralized");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullProviderNameNeverThrows() {
|
||||
FurnitureTextProvider p = provider(true);
|
||||
assertDoesNotThrow(() -> p.getName(null));
|
||||
assertNull(p.getName(null));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ and is developed for free by talented developers and is compatible with the foll
|
||||
[Latest compiled version](https://github.com/duckietm/Arcturus-Morningstar-Extended/tree/main/Latest_Compiled_Version)
|
||||
|
||||
## Connection ##
|
||||
Use the Websocket plugin!
|
||||
Use the BUILD-IN Websocket so do NOT load any websocket plugin!
|
||||
|
||||
### How do I connect to my emulator using Secure Websockets (wss)?
|
||||
You have several options to add WSS support to your websocket server.
|
||||
|
||||
Reference in New Issue
Block a user