diff --git a/Database Updates/007_Frank.sql b/Database Updates/007_Frank.sql
new file mode 100644
index 00000000..24153444
--- /dev/null
+++ b/Database Updates/007_Frank.sql
@@ -0,0 +1,70 @@
+ALTER TABLE `bots`
+ MODIFY COLUMN `type` ENUM('generic','visitor_log','bartender','weapons_dealer','frank')
+ NOT NULL DEFAULT 'generic';
+
+INSERT INTO `permission_definitions`
+ (`permission_key`, `max_value`, `comment`,
+ `rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`)
+VALUES
+ ('acc_bot_frank', 1, 'Required to purchase the Frank mascot bot from the catalog.',
+ 0, 0, 0, 0, 0, 0, 1)
+ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`);
+
+
+CREATE TABLE IF NOT EXISTS `bot_chat_responses` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `bot_type` VARCHAR(32) NOT NULL,
+ `keys` VARCHAR(255) NOT NULL COMMENT 'semicolon-separated trigger words',
+ `responses` TEXT NOT NULL COMMENT 'newline-separated replies; bot picks one at random',
+ PRIMARY KEY (`id`),
+ KEY `bot_type` (`bot_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO `bot_chat_responses` (`bot_type`, `keys`, `responses`) VALUES
+('frank', '__door_triggers', 'show me the door\nkick me\ni want to leave\nlet me out'),
+('frank', '__door_lines', 'Right this way - mind the step!\nAnd out you go. Come back soon!\nAllow me to escort you to the exit.\nThere''s the door. Farewell, true believer!'),
+('frank', '__busy_whisper', 'Sorry, I am currently busy. Please wait until I am available.'),
+('frank', 'frank', 'Hello, I''m Frank! Welcome to Habbo.'),
+('frank', 'help', 'What do you need help with?'),
+('frank', 'thanks;thank you', 'Just doing my job, true believer!'),
+('frank', 'new', 'Welcome to Habbo! I hope you have a great time here.'),
+('frank', 'rooms', 'Looking for somewhere fun? Try the Navigator - thousands of rooms to explore!'),
+('frank', 'sulake', 'Sulake is the company behind Habbo. Take a look: https://www.sulake.com'),
+('frank', 'vip;hc', 'VIP gets you more outfits, more furni, more everything. Worth it!'),
+('frank', 'music', 'Snoop Dogg, Frank Sinatra and a little Beethoven on Sundays.'),
+('frank', 'movie', 'I''m a Casablanca man. Black and white films are an underrated art.'),
+('frank', 'game', 'Battleship. Always Battleship.'),
+('frank', 'snowstorm', 'Honestly? I''m terrible at Snowstorm. Don''t tell anyone.'),
+('frank', 'furni', 'Best furniture maker in town - hands down, the folks at Sulake.'),
+('frank', 'animal;cat;pet','I have a cat called Mr. Whiskers. He runs the place, really.'),
+('frank', 'miranda', 'Miranda. The love of my life. Don''t get me started.'),
+('frank', 'frank black', 'Named after the man himself. Frank Black is a hero of mine.'),
+('frank', 'life', 'Life is like a bowl of popcorn - warm, salty and buttery.'),
+('frank', 'job;work', 'I''m sure you can find work in one of the guest rooms!'),
+('frank', 'snouthill', 'Snouthill... so many memories.'),
+('frank', 'wife', 'I had a wife once. She broke my stereo.'),
+('frank', 'baseball', 'Oh, I used to love to go down to the old ball park and watch Christy Mathewson and Honus Wagner at bat.'),
+('frank', 'mark', 'I don''t trust Mark.'),
+('frank', 'vietnam', 'Vietnam? Don''t ask. Worst trip of my life.'),
+('frank', 'pills;drugs', 'Drugs are bad, mmkay?');
+
+INSERT IGNORE INTO `bot_serves` (`keys`, `item`) VALUES
+('sunflower', 1002),
+('cola;habbo cola', 19),
+('rose', 1000),
+('book', 1003),
+('tea', 27),
+('coffee', 8),
+('migraine;headache;pills', 1015),
+('radioactive liquid;radioactive', 30),
+('turkey;can of turkey', 70);
+
+-- VERY IMPORTANT !!!!
+-- First check if the items_base ID and catalog_items ID is not in use !
+-- After the SQL please go to the catalog_items table and change the page_id to where your BOTS are located
+
+INSERT IGNORE INTO `items_base` (`id`, `sprite_id`, `item_name`, `public_name`, `width`, `length`, `stack_height`, `allow_stack`, `allow_sit`, `allow_lay`, `allow_walk`, `allow_gift`, `allow_trade`, `allow_recycle`, `allow_marketplace_sell`, `allow_inventory_stack`, `type`, `interaction_type`, `interaction_modes_count`, `vending_ids`, `multiheight`, `customparams`)
+VALUES (19001, 0, 'bot_frank', 'Frank', 1, 1, 0.00, '0', '0', '0', '1', '0', '0', '0', '0', '0', 'r', 'default', 1, '0', '0', '0');
+
+INSERT IGNORE INTO `catalog_items` (`item_ids`, `page_id`, `offer_id`, `catalog_name`, `cost_credits`, `cost_points`, `points_type`, `amount`, `extradata`)
+VALUES ('19001', 8, 19001, 'Frank', 0, 0, 0, 1, 'name:Frank;motto:Welcome to Habbo!;figure:hr-3499-33.sh-290-90.ch-3971-72-73.lg-270-73.hd-205-1-1.fa-1206-67.ha-3409-73-72;gender:m');
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 76939bf5..1f4ed186 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.21
+ 4.2.24
UTF-8
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java
index 5c5e5d14..9123c338 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/Bot.java
@@ -539,5 +539,9 @@ public class Bot implements Runnable {
}
}
+ private static final short[] DEFAULT_OWNER_ACTION_IDS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+ public short[] getOwnerActionIds() {
+ return DEFAULT_OWNER_ACTION_IDS;
+ }
}
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java
index 80f820d3..ff2b1d72 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java
@@ -41,6 +41,7 @@ public class BotManager {
addBotDefinition("generic", Bot.class);
addBotDefinition("bartender", ButlerBot.class);
addBotDefinition("visitor_log", VisitorBot.class);
+ addBotDefinition(FrankBot.BOT_TYPE, FrankBot.class);
this.reload();
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java
new file mode 100644
index 00000000..a373f0fc
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/FrankBot.java
@@ -0,0 +1,449 @@
+package com.eu.habbo.habbohotel.bots;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.rooms.*;
+import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.messages.outgoing.rooms.users.RoomUserStatusComposer;
+import com.eu.habbo.messages.outgoing.rooms.users.RoomUserWhisperComposer;
+import com.eu.habbo.threading.runnables.RoomUnitWalkToLocation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Pattern;
+
+public class FrankBot extends ButlerBot {
+ private static final Logger LOGGER = LoggerFactory.getLogger(FrankBot.class);
+
+ public static final String BOT_TYPE = "frank";
+ public static final String PERMISSION_USE = "acc_bot_frank";
+ private static final String KEY_DOOR_LINES = "__door_lines";
+ private static final String KEY_BUSY_WHISPER = "__busy_whisper";
+ private static final String KEY_DOOR_TRIGGERS = "__door_triggers";
+ private static final List DEFAULT_DOOR_LINES = List.of(
+ "Right this way - mind the step!",
+ "And out you go. Come back soon!",
+ "Allow me to escort you to the exit.",
+ "There's the door. Farewell, true believer!"
+ );
+ private static final String DEFAULT_BUSY_WHISPER =
+ "Sorry, I am currently busy. Please wait until I am available.";
+ private static final Pattern DEFAULT_DOOR_PATTERN = Pattern.compile(
+ "\\b(show me the door|kick me|i want to leave|let me out)\\b");
+
+ private static final ConcurrentHashMap> chatResponses = new ConcurrentHashMap<>();
+ private static volatile List doorLines = DEFAULT_DOOR_LINES;
+ private static volatile String busyWhisper = DEFAULT_BUSY_WHISPER;
+ private static volatile Pattern doorTriggerPattern = DEFAULT_DOOR_PATTERN;
+
+ private static final Random RANDOM = new Random();
+
+ private static final int MAX_CHAT_KEYWORDS = 256;
+ private static final int MAX_DOOR_TRIGGERS = 32;
+ private static final int MAX_MESSAGE_LEN = 256;
+ private static final long BUSY_WHISPER_COOLDOWN_MS = 5000L;
+
+ private volatile RoomTile homeTile;
+ private volatile RoomUserRotation homeRotation;
+ private final AtomicBoolean busy = new AtomicBoolean(false);
+ private final AtomicBoolean returnScheduled = new AtomicBoolean(false);
+ private final ConcurrentHashMap lastBusyWhisperAt = new ConcurrentHashMap<>();
+
+ public FrankBot(ResultSet set) throws SQLException {
+ super(set);
+ }
+
+ public FrankBot(Bot bot) {
+ super(bot);
+ }
+
+ @Override
+ public void onPlace(Habbo habbo, Room room) {
+ super.onPlace(habbo, room);
+ if (this.getRoomUnit() != null) {
+ this.homeTile = this.getRoomUnit().getCurrentLocation();
+ this.homeRotation = this.getRoomUnit().getBodyRotation();
+ }
+ }
+
+ private static final short[] FRANK_OWNER_ACTIONS = new short[0];
+
+ @Override
+ public short[] getOwnerActionIds() {
+ return FRANK_OWNER_ACTIONS;
+ }
+
+ public static void initialise() {
+ chatResponses.clear();
+ doorLines = DEFAULT_DOOR_LINES;
+ busyWhisper = DEFAULT_BUSY_WHISPER;
+ doorTriggerPattern = DEFAULT_DOOR_PATTERN;
+
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
+ Statement statement = connection.createStatement();
+ ResultSet set = statement.executeQuery("SELECT `keys`, `responses` FROM bot_chat_responses WHERE bot_type = '" + BOT_TYPE + "'")) {
+ while (set.next()) {
+ String keysRaw = set.getString("keys");
+ String responsesRaw = set.getString("responses");
+
+ if (keysRaw == null || responsesRaw == null) continue;
+
+ List responses = new ArrayList<>();
+ for (String line : responsesRaw.split("\n")) {
+ String trimmed = line.trim();
+ if (!trimmed.isEmpty()) responses.add(trimmed);
+ }
+
+ if (responses.isEmpty()) continue;
+
+ String firstKey = keysRaw.split(";", 2)[0].trim();
+ if (firstKey.startsWith("__")) {
+ switch (firstKey) {
+ case KEY_DOOR_LINES:
+ doorLines = new CopyOnWriteArrayList<>(responses);
+ break;
+ case KEY_BUSY_WHISPER:
+ busyWhisper = responses.get(0);
+ break;
+ case KEY_DOOR_TRIGGERS:
+ doorTriggerPattern = buildDoorTriggerPattern(responses);
+ break;
+ default:
+ LOGGER.warn("FrankBot: unknown system key '{}', ignored", firstKey);
+ }
+ continue;
+ }
+
+ List shared = new CopyOnWriteArrayList<>(responses);
+
+ for (String key : keysRaw.split(";")) {
+ if (chatResponses.size() >= MAX_CHAT_KEYWORDS) {
+ LOGGER.warn("FrankBot: chat keyword cap ({}) reached, remaining rows ignored",
+ MAX_CHAT_KEYWORDS);
+ break;
+ }
+ String k = key == null ? "" : key.trim().toLowerCase();
+ if (k.isEmpty()) continue;
+ try {
+ Pattern pattern = Pattern.compile("\\b" + Pattern.quote(k) + "\\b");
+ chatResponses.put(pattern, shared);
+ } catch (Exception e) {
+ LOGGER.error("Failed to compile Frank chat keyword pattern: {}", k, e);
+ }
+ }
+ }
+ } catch (SQLException e) {
+ LOGGER.warn("FrankBot: could not load bot_chat_responses ({}). Frank will still serve items.", e.getMessage());
+ }
+
+ ButlerBot.initialise();
+ }
+
+ public static void dispose() {
+ chatResponses.clear();
+ doorLines = DEFAULT_DOOR_LINES;
+ busyWhisper = DEFAULT_BUSY_WHISPER;
+ doorTriggerPattern = DEFAULT_DOOR_PATTERN;
+ ButlerBot.dispose();
+ }
+
+ private static Pattern buildDoorTriggerPattern(List triggers) {
+ StringBuilder sb = new StringBuilder("\\b(");
+ boolean first = true;
+ int count = 0;
+ for (String trigger : triggers) {
+ if (count >= MAX_DOOR_TRIGGERS) {
+ LOGGER.warn("FrankBot: door trigger cap ({}) reached, extra entries ignored",
+ MAX_DOOR_TRIGGERS);
+ break;
+ }
+ String t = trigger == null ? "" : trigger.trim().toLowerCase();
+ if (t.isEmpty()) continue;
+ if (!first) sb.append('|');
+ sb.append(Pattern.quote(t));
+ first = false;
+ count++;
+ }
+ sb.append(")\\b");
+
+ if (first) return DEFAULT_DOOR_PATTERN;
+
+ try {
+ return Pattern.compile(sb.toString());
+ } catch (Exception e) {
+ LOGGER.error("FrankBot: failed to compile door trigger pattern from {}, falling back to default", triggers, e);
+ return DEFAULT_DOOR_PATTERN;
+ }
+ }
+
+ @Override
+ public void onUserSay(final RoomChatMessage message) {
+ Room currentRoom = this.getRoom();
+ if (currentRoom == null) return;
+
+ Habbo asker = message.getHabbo();
+ if (asker == null || asker.getClient() == null) return;
+
+ if (this.getRoomUnit() == null) return;
+
+ String raw = message.getUnfilteredMessage();
+ if (raw != null && raw.length() > MAX_MESSAGE_LEN) return;
+
+ if (this.homeTile == null) {
+ this.homeTile = this.getRoomUnit().getCurrentLocation();
+ this.homeRotation = this.getRoomUnit().getBodyRotation();
+ }
+
+ if (this.busy.get() || this.getRoomUnit().hasStatus(RoomUnitStatus.MOVE)) {
+ this.whisperThrottled(asker, busyWhisper);
+ return;
+ }
+
+ if (raw != null) {
+ double distance = this.getRoomUnit().getCurrentLocation().distance(asker.getRoomUnit().getCurrentLocation());
+ int commandDistance = Emulator.getConfig().getInt("hotel.bot.butler.commanddistance");
+
+ if (distance <= commandDistance) {
+ String lower = raw.toLowerCase();
+
+ if (doorTriggerPattern.matcher(lower).find()) {
+ if (!this.busy.compareAndSet(false, true)) {
+ this.whisperThrottled(asker, busyWhisper);
+ return;
+ }
+ this.showToTheDoor(asker);
+ return;
+ }
+
+ for (java.util.Map.Entry> entry : chatResponses.entrySet()) {
+ if (entry.getKey().matcher(lower).find()) {
+ List options = entry.getValue();
+ if (options.isEmpty()) continue;
+
+ String reply = options.get(RANDOM.nextInt(options.size()));
+ this.talk(reply);
+ return;
+ }
+ }
+ }
+ }
+
+ if (!this.busy.compareAndSet(false, true)) {
+ this.whisperThrottled(asker, busyWhisper);
+ return;
+ }
+ super.onUserSay(message);
+ this.schedulePostServeReturn(currentRoom.getId(), 0);
+ }
+
+ private void whisperThrottled(Habbo target, String text) {
+ if (target == null || text == null || text.isEmpty() || this.getRoomUnit() == null) return;
+ int userId = target.getHabboInfo().getId();
+ long now = System.currentTimeMillis();
+ Long last = lastBusyWhisperAt.get(userId);
+ if (last != null && (now - last) < BUSY_WHISPER_COOLDOWN_MS) return;
+ lastBusyWhisperAt.put(userId, now);
+ RoomChatMessage msg = new RoomChatMessage(text, this.getRoomUnit(), RoomChatMessageBubbles.BOT);
+ target.getClient().sendResponse(new RoomUserWhisperComposer(msg));
+ }
+
+ private void showToTheDoor(final Habbo target) {
+ final Room room = this.getRoom();
+ if (room == null || room.getLayout() == null || target == null) {
+ this.busy.set(false);
+ return;
+ }
+
+ final RoomTile doorTile = room.getLayout().getDoorTile();
+ if (doorTile == null) {
+ this.busy.set(false);
+ return;
+ }
+
+ this.lookAt(target);
+ List lines = doorLines;
+ String line = lines.isEmpty() ? DEFAULT_DOOR_LINES.get(RANDOM.nextInt(DEFAULT_DOOR_LINES.size()))
+ : lines.get(RANDOM.nextInt(lines.size()));
+ this.talk(line);
+
+ final int targetId = target.getHabboInfo().getId();
+ final int roomId = room.getId();
+ final AtomicBoolean fired = new AtomicBoolean(false);
+
+ final Runnable kickThenReturn = () -> {
+ if (!fired.compareAndSet(false, true)) return;
+ Room currentRoom = this.getRoom();
+ if (currentRoom == null || currentRoom.getId() != roomId) {
+ this.busy.set(false);
+ return;
+ }
+ Habbo stillHere = currentRoom.getHabbo(targetId);
+ if (stillHere != null) {
+ currentRoom.kickHabbo(stillHere, false);
+ }
+ this.scheduleReturnHome(targetId, roomId, 0);
+ };
+
+ if (this.getRoomUnit().canWalk() && !this.getRoomUnit().getCurrentLocation().equals(doorTile)) {
+ List onArrive = new ArrayList<>();
+ onArrive.add(kickThenReturn);
+
+ List onFail = new ArrayList<>();
+ onFail.add(() -> Emulator.getThreading().run(kickThenReturn, 1500));
+
+ this.getRoomUnit().setGoalLocation(doorTile);
+ Emulator.getThreading().run(
+ new RoomUnitWalkToLocation(this.getRoomUnit(), doorTile, room, onArrive, onFail));
+ } else {
+ Emulator.getThreading().run(kickThenReturn, 1500);
+ }
+ }
+
+ private static final int RETURN_HOME_POLL_MS = 500;
+ private static final int RETURN_HOME_MAX_WAIT_MS = 8000;
+ private static final int POST_SERVE_POLL_MS = 750;
+ private static final int POST_SERVE_MAX_WAIT_MS = 30000;
+
+ private void schedulePostServeReturn(final int roomId, final int waitedMs) {
+ if (waitedMs == 0 && !this.returnScheduled.compareAndSet(false, true)) {
+ return;
+ }
+ if (waitedMs >= POST_SERVE_MAX_WAIT_MS) {
+ this.returnScheduled.set(false);
+ this.busy.set(false);
+ return;
+ }
+ if (this.homeTile == null) {
+ this.returnScheduled.set(false);
+ this.busy.set(false);
+ return;
+ }
+
+ Emulator.getThreading().run(() -> {
+ Room r = this.getRoom();
+ if (r == null || r.getId() != roomId || this.getRoomUnit() == null || this.homeTile == null) {
+ this.returnScheduled.set(false);
+ this.busy.set(false);
+ return;
+ }
+
+ if (this.getRoomUnit().getCurrentLocation().equals(this.homeTile)) {
+ if (this.homeRotation != null && this.getRoomUnit().getBodyRotation() != this.homeRotation) {
+ this.getRoomUnit().setRotation(this.homeRotation);
+ r.sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
+ this.persistPosition();
+ } else {
+ this.busy.set(false);
+ }
+ this.returnScheduled.set(false);
+ return;
+ }
+
+ boolean stillWalking = this.getRoomUnit().hasStatus(RoomUnitStatus.MOVE)
+ || (this.getRoomUnit().getPath() != null && !this.getRoomUnit().getPath().isEmpty());
+
+ if (stillWalking) {
+ this.schedulePostServeReturn(roomId, waitedMs + POST_SERVE_POLL_MS);
+ return;
+ }
+
+ this.returnScheduled.set(false);
+ this.returnHome(-1, false);
+ }, POST_SERVE_POLL_MS);
+ }
+
+ private void scheduleReturnHome(final int kickedHabboId, final int roomId, final int waitedMs) {
+ Room currentRoom = this.getRoom();
+ if (currentRoom == null || currentRoom.getId() != roomId) return;
+
+ boolean stillEscorting = currentRoom.getHabbo(kickedHabboId) != null;
+
+ if (!stillEscorting || waitedMs >= RETURN_HOME_MAX_WAIT_MS) {
+ this.returnHome(kickedHabboId, true);
+ return;
+ }
+
+ Emulator.getThreading().run(
+ () -> this.scheduleReturnHome(kickedHabboId, roomId, waitedMs + RETURN_HOME_POLL_MS),
+ RETURN_HOME_POLL_MS);
+ }
+
+ private void returnHome(int kickedHabboId, boolean alwaysTeleport) {
+ final Room room = this.getRoom();
+ if (room == null || this.homeTile == null || this.getRoomUnit() == null) {
+ this.busy.set(false);
+ return;
+ }
+
+ final Runnable teleportHome = () -> {
+ Room r = this.getRoom();
+ if (r == null || this.getRoomUnit() == null) return;
+
+ double homeZ = r.getTopHeightAt(this.homeTile.x, this.homeTile.y);
+
+ this.getRoomUnit().stopWalking();
+ this.getRoomUnit().setZ(homeZ);
+ this.getRoomUnit().setLocation(this.homeTile);
+ this.getRoomUnit().setPreviousLocationZ(homeZ);
+ if (this.homeRotation != null) {
+ this.getRoomUnit().setRotation(this.homeRotation);
+ }
+ this.getRoomUnit().statusUpdate(true);
+ r.sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
+ this.persistPosition();
+ };
+
+ if (this.getRoomUnit().getCurrentLocation().equals(this.homeTile)) {
+ if (this.homeRotation != null) {
+ this.getRoomUnit().setRotation(this.homeRotation);
+ room.sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
+ }
+ this.persistPosition();
+ return;
+ }
+
+ boolean hasOtherWatchers = false;
+ for (Habbo h : room.getCurrentHabbos().values()) {
+ if (h.getHabboInfo().getId() != kickedHabboId) {
+ hasOtherWatchers = true;
+ break;
+ }
+ }
+
+ if (alwaysTeleport || !hasOtherWatchers || !this.getRoomUnit().canWalk()) {
+ teleportHome.run();
+ return;
+ }
+
+ List onArrive = new ArrayList<>();
+ onArrive.add(() -> {
+ if (this.homeRotation != null && this.getRoom() != null) {
+ this.getRoomUnit().setRotation(this.homeRotation);
+ this.getRoom().sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
+ }
+ this.persistPosition();
+ });
+
+ List onFail = new ArrayList<>();
+ onFail.add(teleportHome);
+
+ this.getRoomUnit().setGoalLocation(this.homeTile);
+ Emulator.getThreading().run(
+ new RoomUnitWalkToLocation(this.getRoomUnit(), this.homeTile, room, onArrive, onFail));
+ }
+
+ private void persistPosition() {
+ this.needsUpdate(true);
+ this.run();
+ this.busy.set(false);
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java
index 76452042..c89bf959 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java
@@ -1099,10 +1099,22 @@ public class CatalogManager {
for (Item baseItem : item.getBaseItems()) {
for (int k = 0; k < item.getItemAmount(baseItem.getId()); k++) {
if (baseItem.getName().startsWith("rentable_bot_") || baseItem.getName().startsWith("bot_")) {
+ String baseName = baseItem.getName();
String type = item.getName().replace("rentable_bot_", "");
type = type.replace("bot_", "");
type = type.replace("visitor_logger", "visitor_log");
+ // Permission gate keyed on the canonical base-item name
+ // (admin-controlled but stable), not the catalog page name
+ // which can be renamed and bypass the check.
+ if (("bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)
+ || ("rentable_bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)) {
+ if (!habbo.getClient().getHabbo().hasPermission(com.eu.habbo.habbohotel.bots.FrankBot.PERMISSION_USE)) {
+ habbo.getClient().sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR).compose());
+ return;
+ }
+ }
+
THashMap data = new THashMap<>();
for (String s : item.getExtradata().split(";")) {
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java
index 2a208c2f..992ef724 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java
@@ -175,6 +175,10 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
CatalogItem item = page.getCatalogItem(itemId);
+ // Search-results gift sends the catalog offer_id as
+ // itemId, not catalog_items.id - see the same fix in
+ // CatalogBuyItemEvent. Fall back to scanning the
+ // page for the matching offer_id.
if (item == null) {
for (CatalogItem candidate : page.getCatalogItems().valueCollection()) {
if (candidate != null && candidate.getOfferId() == itemId) {
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java
index e948a5eb..a80067b7 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java
@@ -14,7 +14,11 @@ import com.eu.habbo.habbohotel.users.HabboBadge;
import com.eu.habbo.habbohotel.users.HabboInventory;
import com.eu.habbo.habbohotel.users.subscriptions.Subscription;
import com.eu.habbo.messages.incoming.MessageHandler;
-import com.eu.habbo.messages.outgoing.catalog.*;
+import com.eu.habbo.messages.outgoing.catalog.AlertPurchaseFailedComposer;
+import com.eu.habbo.messages.outgoing.catalog.AlertPurchaseUnavailableComposer;
+import com.eu.habbo.messages.outgoing.catalog.BuildersClubFurniCountComposer;
+import com.eu.habbo.messages.outgoing.catalog.BuildersClubSubscriptionStatusComposer;
+import com.eu.habbo.messages.outgoing.catalog.PurchaseOKComposer;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
import com.eu.habbo.messages.outgoing.generic.alerts.HotelWillCloseInMinutesComposer;
@@ -199,6 +203,12 @@ public class CatalogBuyItemEvent extends MessageHandler {
else
item = page.getCatalogItem(itemId);
+ // Search-results buy sends the catalog offer_id as itemId
+ // (FurnitureOffer.offerId is derived from furnidata's
+ // purchaseOfferId, which matches `catalog_items.offer_id`),
+ // not the `catalog_items.id` primary key that getCatalogItem
+ // expects. Fall back to scanning the page for the matching
+ // offer_id so the search → buy flow works.
if (item == null && !(page instanceof RecentPurchasesLayout)) {
for (CatalogItem candidate : page.getCatalogItems().valueCollection()) {
if (candidate != null && candidate.getOfferId() == itemId) {
@@ -207,7 +217,13 @@ public class CatalogBuyItemEvent extends MessageHandler {
}
}
}
-
+ // Inventory cap check based on the actual base items the
+ // purchase will create, not the page layout - bots/pets
+ // can legitimately live on bundle pages, search results,
+ // recent-purchases, etc., and the layout-instanceof check
+ // missed all those paths. Mirrors the bot/pet branches
+ // inside CatalogManager.purchaseItem (Item.isBot / isPet
+ // and the same prefix check) so detection stays in sync.
boolean itemHasBot = false;
boolean itemHasPet = false;
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/floorplaneditor/FloorPlanEditorSaveEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/floorplaneditor/FloorPlanEditorSaveEvent.java
index bd2bd76a..f9e7d3b4 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/floorplaneditor/FloorPlanEditorSaveEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/floorplaneditor/FloorPlanEditorSaveEvent.java
@@ -17,7 +17,11 @@ import gnu.trove.set.hash.THashSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.StringJoiner;
import java.util.regex.Pattern;
public class FloorPlanEditorSaveEvent extends MessageHandler {
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java
index 0622a6b2..6d7c8d21 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/bots/BotSaveSettingsEvent.java
@@ -34,6 +34,9 @@ public class BotSaveSettingsEvent extends MessageHandler {
if (bot == null)
return;
+ if (bot.getOwnerActionIds().length == 0)
+ return;
+
int settingId = this.packet.readInt();
switch (settingId) {
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java
index 8b651b48..51e00c4c 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java
@@ -86,7 +86,7 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendInt(habbo.getHabboInfo().getId());
this.response.appendString(habbo.getHabboInfo().getUsername());
this.response.appendString(habbo.getHabboInfo().getMotto());
- this.response.appendInt(habbo.getHabboInfo().getInfostandBg());
+ this.response.appendInt(habbo.getHabboInfo().getInfostandBg());
this.response.appendInt(habbo.getHabboInfo().getInfostandStand());
this.response.appendInt(habbo.getHabboInfo().getInfostandOverlay());
this.response.appendInt(habbo.getHabboInfo().getInfostandCardBg());
@@ -129,7 +129,7 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendInt(0 - this.bot.getId());
this.response.appendString(this.bot.getName());
this.response.appendString(this.bot.getMotto());
- this.response.appendInt(0);
+ this.response.appendInt(0);
this.response.appendInt(0);
this.response.appendInt(0);
this.response.appendInt(0);
@@ -143,17 +143,11 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendString(this.bot.getGender().name().toUpperCase());
this.response.appendInt(this.bot.getOwnerId());
this.response.appendString(this.bot.getOwnerName());
- this.response.appendInt(10);
- this.response.appendShort(0);
- this.response.appendShort(1);
- this.response.appendShort(2);
- this.response.appendShort(3);
- this.response.appendShort(4);
- this.response.appendShort(5);
- this.response.appendShort(6);
- this.response.appendShort(7);
- this.response.appendShort(8);
- this.response.appendShort(9);
+ short[] singleActions = this.bot.getOwnerActionIds();
+ this.response.appendInt(singleActions.length);
+ for (short action : singleActions) {
+ this.response.appendShort(action);
+ }
this.response.appendString("unknown");
this.response.appendInt(0);
this.response.appendInt(0);
@@ -163,10 +157,10 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendInt(0 - bot.getId());
this.response.appendString(bot.getName());
this.response.appendString(bot.getMotto());
- this.response.appendInt(0);
- this.response.appendInt(0);
- this.response.appendInt(0);
- this.response.appendInt(0);
+ this.response.appendInt(0);
+ this.response.appendInt(0);
+ this.response.appendInt(0);
+ this.response.appendInt(0);
this.response.appendString(bot.getFigure());
this.response.appendInt(bot.getRoomUnit().getId());
this.response.appendInt(bot.getRoomUnit().getX());
@@ -177,17 +171,11 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendString(bot.getGender().name().toUpperCase());
this.response.appendInt(bot.getOwnerId());
this.response.appendString(bot.getOwnerName());
- this.response.appendInt(10);
- this.response.appendShort(0);
- this.response.appendShort(1);
- this.response.appendShort(2);
- this.response.appendShort(3);
- this.response.appendShort(4);
- this.response.appendShort(5);
- this.response.appendShort(6);
- this.response.appendShort(7);
- this.response.appendShort(8);
- this.response.appendShort(9);
+ short[] listActions = bot.getOwnerActionIds();
+ this.response.appendInt(listActions.length);
+ for (short action : listActions) {
+ this.response.appendShort(action);
+ }
this.response.appendString("unknown");
this.response.appendInt(0);
this.response.appendInt(0);