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
Merge branch 'dev' into chore/deps-resilience-validation
This commit is contained in:
@@ -435,6 +435,16 @@ ON DUPLICATE KEY UPDATE
|
||||
`triggers_talking_furniture` = VALUES(`triggers_talking_furniture`);
|
||||
|
||||
INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.acc_modtool_room_info', 'Allows viewing room information in the moderation tool.'),
|
||||
('commands.description.cmd_add_youtube_playlist', ':add_youtube <base_item_id> <youtube_playlist_id>'),
|
||||
('commands.description.cmd_disablemassmentions', ':disablemassmentions'),
|
||||
('commands.description.cmd_disablementions', ':disablementions'),
|
||||
('commands.description.cmd_give_prefix', ':giveprefix <username> <text> <color> [icon] [effect]'),
|
||||
('commands.description.cmd_hidewired', ':hidewired'),
|
||||
('commands.description.cmd_list_prefixes', ':listprefixes <username>'),
|
||||
('commands.description.cmd_remove_prefix', ':removeprefix <username> <id|all>'),
|
||||
('commands.description.cmd_setroom_template', ':setroom_template'),
|
||||
('commands.description.cmd_update_youtube_playlists', ':update_youtube'),
|
||||
('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'),
|
||||
('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'),
|
||||
('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'),
|
||||
|
||||
@@ -37,6 +37,10 @@ VALUES
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`comment` = VALUES(`comment`);
|
||||
|
||||
INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_disablementions', ':disablementions'),
|
||||
('commands.description.cmd_disablemassmentions', ':disablemassmentions');
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. Emulator settings: cooldowns, caps and alias lists
|
||||
|
||||
@@ -49,6 +49,7 @@ INSERT INTO `permission_definitions` (`permission_key`, `rank_7`, `comment`) VAL
|
||||
ON DUPLICATE KEY UPDATE `rank_7` = VALUES(`rank_7`);
|
||||
|
||||
INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_setroom_template', ':setroom_template'),
|
||||
('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'),
|
||||
('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'),
|
||||
('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'),
|
||||
|
||||
@@ -301,6 +301,7 @@ INSERT IGNORE INTO `custom_prefixes_catalog`
|
||||
|
||||
INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
-- GivePrefix command
|
||||
('commands.description.cmd_give_prefix', ':giveprefix <username> <text> <color> [icon] [effect]'),
|
||||
('commands.keys.cmd_give_prefix', 'giveprefix'),
|
||||
('commands.error.cmd_give_prefix.usage', 'Usage: :giveprefix <username> <text> <color> [icon] [effect]'),
|
||||
('commands.error.cmd_give_prefix.invalid_color', 'Invalid color format. Use hex format (#FF0000).'),
|
||||
@@ -308,12 +309,14 @@ INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.error.cmd_give_prefix.user_not_found', 'User not found or not online.'),
|
||||
('commands.succes.cmd_give_prefix', 'Prefix {%prefix%} successfully given to %user%!'),
|
||||
-- ListPrefixes command
|
||||
('commands.description.cmd_list_prefixes', ':listprefixes <username>'),
|
||||
('commands.keys.cmd_list_prefixes', 'listprefixes'),
|
||||
('commands.error.cmd_list_prefixes.usage', 'Usage: :listprefixes <username>'),
|
||||
('commands.error.cmd_list_prefixes.user_not_found', 'User not found or not online.'),
|
||||
('commands.succes.cmd_list_prefixes.header', 'Prefixes of %user%:'),
|
||||
('commands.succes.cmd_list_prefixes.empty', '%user% has no prefixes.'),
|
||||
-- RemovePrefix command
|
||||
('commands.description.cmd_remove_prefix', ':removeprefix <username> <id|all>'),
|
||||
('commands.keys.cmd_remove_prefix', 'removeprefix'),
|
||||
('commands.error.cmd_remove_prefix.usage', 'Usage: :removeprefix <username> <id|all>'),
|
||||
('commands.error.cmd_remove_prefix.user_not_found', 'User not found or not online.'),
|
||||
|
||||
@@ -15355,7 +15355,9 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.cmd_promote_offer.list', 'All available offers (%amount%):<br>%list%'),
|
||||
('commands.cmd_promote_offer.list.entry', '%id%: %title% %description%'),
|
||||
('commands.description.acc_debug', ':test [header] i:1 s:a b:1'),
|
||||
('commands.description.acc_modtool_room_info', 'Allows viewing room information in the moderation tool.'),
|
||||
('commands.description.cmd_about', ':about'),
|
||||
('commands.description.cmd_add_youtube_playlist', ':add_youtube <base_item_id> <youtube_playlist_id>'),
|
||||
('commands.description.cmd_alert', ':alert <username> <message>'),
|
||||
('commands.description.cmd_allow_trading', 'Enables / Disables the tradelock for a user.'),
|
||||
('commands.description.cmd_badge', ':badge <username> <badge>'),
|
||||
@@ -15379,6 +15381,8 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_danceall', ':danceall <dance id>'),
|
||||
('commands.description.cmd_diagonal', ':diagonal'),
|
||||
('commands.description.cmd_disable_effects', ':disableffects'),
|
||||
('commands.description.cmd_disablemassmentions', ':disablemassmentions'),
|
||||
('commands.description.cmd_disablementions', ':disablementions'),
|
||||
('commands.description.cmd_disconnect', ':disconnect <username>'),
|
||||
('commands.description.cmd_duckets', ':duckets <username> <amount>'),
|
||||
('commands.description.cmd_ejectall', ':ejectall'),
|
||||
@@ -15395,11 +15399,13 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_freeze_bots', ':freezebots'),
|
||||
('commands.description.cmd_furnidata', ':furnidata'),
|
||||
('commands.description.cmd_gift', ':gift <username> <itemid>'),
|
||||
('commands.description.cmd_give_prefix', ':giveprefix <username> <text> <color> [icon] [effect]'),
|
||||
('commands.description.cmd_give_rank', ':giverank <username> <rank>'),
|
||||
('commands.description.cmd_ha', ':ha <message>'),
|
||||
('commands.description.cmd_hal', ':hal <url> <message>'),
|
||||
('commands.description.cmd_hand_item', ':handitem <itemid>'),
|
||||
('commands.description.cmd_happyhour', ':happyhour'),
|
||||
('commands.description.cmd_hidewired', ':hidewired'),
|
||||
('commands.description.cmd_hoverboard', ':hoverboard'),
|
||||
('commands.description.cmd_hug', ':hug <username>'),
|
||||
('commands.description.cmd_invisible', ':invisible'),
|
||||
@@ -15408,6 +15414,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_kill', ':kill <username>'),
|
||||
('commands.description.cmd_kiss', ':kiss <username>'),
|
||||
('commands.description.cmd_lay', ':lay'),
|
||||
('commands.description.cmd_list_prefixes', ':listprefixes <username>'),
|
||||
('commands.description.cmd_machine_ban', ':machineban <username> [reason]'),
|
||||
('commands.description.cmd_massbadge', ':massbadge <badge>'),
|
||||
('commands.description.cmd_masscredits', ':masscredits <amount>'),
|
||||
@@ -15429,6 +15436,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_push', ':push <username>'),
|
||||
('commands.description.cmd_redeem', ':redeem'),
|
||||
('commands.description.cmd_reload_room', ':reload_room'),
|
||||
('commands.description.cmd_remove_prefix', ':removeprefix <username> <id|all>'),
|
||||
('commands.description.cmd_roomalert', ':roomalert <message>'),
|
||||
('commands.description.cmd_roombadge', ':roombadge <badge>'),
|
||||
('commands.description.cmd_roomcredits', ':roomcredits <amount>'),
|
||||
@@ -15444,6 +15452,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_set', ':set info'),
|
||||
('commands.description.cmd_setmax', ':setmax <amount>'),
|
||||
('commands.description.cmd_setpublic', ':setpublic'),
|
||||
('commands.description.cmd_setroom_template', ':setroom_template'),
|
||||
('commands.description.cmd_setrotation', ':rot;rotation'),
|
||||
('commands.description.cmd_setspeed', ':setspeed <speed>'),
|
||||
('commands.description.cmd_setstate', ':ss'),
|
||||
@@ -15486,6 +15495,7 @@ INSERT INTO `emulator_texts` (`key`, `value`) VALUES
|
||||
('commands.description.cmd_update_polls', ':update_polls'),
|
||||
('commands.description.cmd_update_texts', ':update_texts'),
|
||||
('commands.description.cmd_update_wordfilter', ':update_word_filter'),
|
||||
('commands.description.cmd_update_youtube_playlists', ':update_youtube'),
|
||||
('commands.description.cmd_userinfo', ':userinfo <username>'),
|
||||
('commands.description.cmd_welcome', ':welcome <username>'),
|
||||
('commands.description.cmd_word_quiz', ':wordquiz <question>'),
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.eu.habbo.plugin.events.emulator.EmulatorStartShutdownEvent;
|
||||
import com.eu.habbo.plugin.events.emulator.EmulatorStoppedEvent;
|
||||
import com.eu.habbo.threading.ThreadPooling;
|
||||
import com.eu.habbo.util.imager.badges.BadgeImager;
|
||||
import com.eu.habbo.util.logback.ConsoleStyle;
|
||||
import org.fusesource.jansi.AnsiConsole;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -38,6 +40,12 @@ public final class Emulator {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Emulator.class);
|
||||
private static final String OS_NAME = (System.getProperty("os.name") != null ? System.getProperty("os.name") : "Unknown");
|
||||
private static final String CLASS_PATH = (System.getProperty("java.class.path") != null ? System.getProperty("java.class.path") : "Unknown");
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
private static final String ANSI_BOLD = "\u001B[1m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_YELLOW = "\u001B[33m";
|
||||
private static final String ANSI_DIM = "\u001B[2m";
|
||||
|
||||
// Fallback version, only used when running outside a packaged jar (e.g. from
|
||||
// the IDE). In production the version comes from the jar manifest below.
|
||||
@@ -65,7 +73,6 @@ public final class Emulator {
|
||||
"██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝███████║ ██║ ██║ ██║██║ ██║\n" +
|
||||
"╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝\n" +
|
||||
"Still Rocking in 2026.\n";
|
||||
|
||||
public static String build = "";
|
||||
public static long buildTimestamp = -1L;
|
||||
|
||||
@@ -104,14 +111,12 @@ public final class Emulator {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
try {
|
||||
if (OS_NAME.startsWith("Windows") && !CLASS_PATH.contains("idea_rt.jar")) {
|
||||
ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
|
||||
ConsoleAppender<ILoggingEvent> appender = (ConsoleAppender<ILoggingEvent>) root.getAppender("Console");
|
||||
|
||||
appender.stop();
|
||||
appender.setWithJansi(true);
|
||||
appender.start();
|
||||
}
|
||||
boolean styledConsole = shouldStyleConsole(
|
||||
System.getenv(),
|
||||
System.console() != null,
|
||||
OS_NAME,
|
||||
System.getProperty("habbo.console.style", "auto"));
|
||||
configureAnsiConsole(styledConsole);
|
||||
|
||||
Locale.setDefault(Locale.of("en"));
|
||||
setBuild();
|
||||
@@ -119,7 +124,7 @@ public final class Emulator {
|
||||
ConsoleCommand.load();
|
||||
Emulator.logging = new Logging();
|
||||
|
||||
System.out.println(logo);
|
||||
System.out.println(startupHero(styledConsole));
|
||||
|
||||
long startTime = System.nanoTime();
|
||||
|
||||
@@ -153,23 +158,10 @@ public final class Emulator {
|
||||
Emulator.config.register("camera.price.points.type", "5");
|
||||
Emulator.config.register("camera.render.delay", "5");
|
||||
Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId());
|
||||
Emulator.config.register("rcon.rate_limit.enabled", "1");
|
||||
Emulator.config.register("rcon.rate_limit.limit_for_period", "60");
|
||||
Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000");
|
||||
Emulator.config.register("rcon.rate_limit.timeout_ms", "0");
|
||||
Emulator.config.register("rcon.mute.max_duration_seconds", "604800");
|
||||
Emulator.config.register("rcon.achievement.max_progress", "10000");
|
||||
Emulator.config.register("rcon.execute_command.max_length", "256");
|
||||
Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_update_config;cmd_update_permissions;cmd_give_rank;cmd_badge;cmd_gift;cmd_credits;cmd_points;cmd_pixels;cmd_massbadge;cmd_masscredits;cmd_massgift;cmd_massduckets;cmd_masspoints;cmd_empty;cmd_empty_bots;cmd_empty_pets;cmd_unload;cmd_ban;cmd_superban;cmd_ip_ban;cmd_machine_ban;cmd_disconnect");
|
||||
Emulator.config.register("rcon.execute_command.allowed_permissions", "");
|
||||
Emulator.config.register("gui.enabled", "0");
|
||||
Emulator.config.register("gui.autostart.enabled", "0");
|
||||
String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId());
|
||||
System.out.println();
|
||||
LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, ");
|
||||
System.out.println();
|
||||
LOGGER.info("This project is for educational purposes only. This Emulator is an open-source fork of Arcturus created by TheGeneral.");
|
||||
LOGGER.info("Version: {}", version);
|
||||
LOGGER.info("Build: {}", build);
|
||||
LOGGER.info("Build Timestamp: {} [{}]", formatBuildTimestamp(buildTimestamp, hotelTimezoneId), hotelTimezoneId);
|
||||
System.out.println(startupCard(hotelTimezoneId));
|
||||
Emulator.texts.register("camera.permission", "You don't have permission to use the camera!");
|
||||
Emulator.texts.register("camera.wait", "Please wait %seconds% seconds before making another picture.");
|
||||
Emulator.texts.register("camera.error.creation", "Failed to create your picture. *sadpanda*");
|
||||
@@ -207,7 +199,7 @@ public final class Emulator {
|
||||
Emulator.isReady = true;
|
||||
Emulator.timeStarted = getIntUnixTimestamp();
|
||||
|
||||
if (Emulator.getConfig().getBoolean("gui.enabled", true)) {
|
||||
if (shouldLaunchGui()) {
|
||||
EmulatorDashboard.launch();
|
||||
}
|
||||
|
||||
@@ -319,6 +311,97 @@ public final class Emulator {
|
||||
return -1L;
|
||||
}
|
||||
|
||||
static String startupCard(String hotelTimezoneId) {
|
||||
return "\n" +
|
||||
"+----------------------------------------------------------------+\n" +
|
||||
"| Arcturus Morningstar Extended |\n" +
|
||||
"| Source : github.com/duckietm/Arcturus-Morningstar-Extended |\n" +
|
||||
"| Scope : Educational open-source fork by TheGeneral |\n" +
|
||||
"| Version: " + version + "\n" +
|
||||
"| Build : " + build + "\n" +
|
||||
"| Time : " + formatBuildTimestamp(buildTimestamp, hotelTimezoneId) + " [" + hotelTimezoneId + "]\n" +
|
||||
"+----------------------------------------------------------------+\n";
|
||||
}
|
||||
|
||||
static String startupHero() {
|
||||
return startupHero(false);
|
||||
}
|
||||
|
||||
static String startupHero(boolean styled) {
|
||||
if (styled) {
|
||||
return "\n" +
|
||||
ANSI_CYAN +
|
||||
" __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" +
|
||||
" | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" +
|
||||
" | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" +
|
||||
" | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" +
|
||||
" |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" +
|
||||
ANSI_RESET +
|
||||
"\n" +
|
||||
ANSI_DIM + "+------------------------------------------------------------------------------+" + ANSI_RESET + "\n" +
|
||||
"| " + ANSI_BOLD + ANSI_GREEN + "[OK] MORNINGSTAR EXTENDED" + ANSI_RESET + fit("", 50) + " |\n" +
|
||||
"| " + ANSI_DIM + "Arcturus game server runtime" + ANSI_RESET + fit("", 48) + " |\n" +
|
||||
ANSI_DIM + "+------------------------------------------------------------------------------+" + ANSI_RESET + "\n" +
|
||||
"| " + ANSI_YELLOW + "[VER]" + ANSI_RESET + " Version : " + fit(version, 57) + " |\n" +
|
||||
"| " + ANSI_YELLOW + "[BLD]" + ANSI_RESET + " Build : " + fit(build.isBlank() ? "UNKNOWN" : build, 57) + " |\n" +
|
||||
"| " + ANSI_YELLOW + "[JVM]" + ANSI_RESET + " Runtime : " + fit("Java " + System.getProperty("java.version", "unknown") + " / styled console output", 57) + " |\n" +
|
||||
ANSI_DIM + "+------------------------------------------------------------------------------+" + ANSI_RESET + "\n";
|
||||
}
|
||||
|
||||
return "\n" +
|
||||
" __ __ ___ ____ _ _ ___ _ _ ____ ____ _____ _ ____ \n" +
|
||||
" | \\/ |/ _ \\| _ \\| \\ | |_ _| \\ | |/ ___/ ___|_ _|/ \\ | _ \\ \n" +
|
||||
" | |\\/| | | | | |_) | \\| || || \\| | | _\\___ \\ | | / _ \\ | |_) |\n" +
|
||||
" | | | | |_| | _ <| |\\ || || |\\ | |_| |___) || |/ ___ \\| _ < \n" +
|
||||
" |_| |_|\\___/|_| \\_\\_| \\_|___|_| \\_|\\____|____/ |_/_/ \\_\\_| \\_\\\n" +
|
||||
"\n" +
|
||||
"+------------------------------------------------------------------------------+\n" +
|
||||
"| MORNINGSTAR EXTENDED |\n" +
|
||||
"| Arcturus game server runtime |\n" +
|
||||
"+------------------------------------------------------------------------------+\n" +
|
||||
"| Version : " + fit(version, 63) + " |\n" +
|
||||
"| Build : " + fit(build.isBlank() ? "UNKNOWN" : build, 63) + " |\n" +
|
||||
"| Runtime : " + fit("Java " + System.getProperty("java.version", "unknown") + " / universal console output", 63) + " |\n" +
|
||||
"+------------------------------------------------------------------------------+\n";
|
||||
}
|
||||
|
||||
static boolean shouldStyleConsole(Map<String, String> environment, boolean interactiveConsole, String osName, String styleProperty) {
|
||||
return ConsoleStyle.isEnabled(environment, interactiveConsole, osName, styleProperty);
|
||||
}
|
||||
|
||||
static void configureAnsiConsole(boolean styledConsole) {
|
||||
if (!styledConsole || !OS_NAME.startsWith("Windows") || CLASS_PATH.contains("idea_rt.jar")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AnsiConsole.systemInstall();
|
||||
|
||||
ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
|
||||
ConsoleAppender<ILoggingEvent> appender = (ConsoleAppender<ILoggingEvent>) root.getAppender("Console");
|
||||
if (appender != null) {
|
||||
appender.stop();
|
||||
appender.setWithJansi(true);
|
||||
appender.start();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Unable to install Jansi console bridge; continuing with raw console output.", e);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean shouldLaunchGui() {
|
||||
return Emulator.getConfig() != null && Emulator.getConfig().getBoolean("gui.autostart.enabled", false);
|
||||
}
|
||||
|
||||
private static String fit(String value, int width) {
|
||||
String safe = value == null ? "" : value;
|
||||
if (safe.length() > width) {
|
||||
return safe.substring(0, Math.max(0, width - 3)) + "...";
|
||||
}
|
||||
|
||||
return String.format("%-" + width + "s", safe);
|
||||
}
|
||||
|
||||
private static String formatBuildTimestamp(long buildTimestamp, String timezoneId) {
|
||||
if (buildTimestamp <= 0) {
|
||||
return "UNKNOWN";
|
||||
|
||||
@@ -52,6 +52,10 @@ public class TextsManager {
|
||||
return this.texts.getProperty(key, defaultValue);
|
||||
}
|
||||
|
||||
public String getValueQuietly(String key, String defaultValue) {
|
||||
return this.texts.getProperty(key, defaultValue);
|
||||
}
|
||||
|
||||
public boolean getBoolean(String key) {
|
||||
return this.getBoolean(key, false);
|
||||
}
|
||||
|
||||
@@ -179,9 +179,13 @@ public class BotManager {
|
||||
}
|
||||
|
||||
public void pickUpBot(Bot bot, Habbo habbo) {
|
||||
HabboInfo receiverInfo = habbo == null ? Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId()) : habbo.getHabboInfo();
|
||||
|
||||
if (bot != null) {
|
||||
HabboInfo receiverInfo = resolvePickupReceiver(bot, habbo);
|
||||
Room botRoom = bot.getRoom();
|
||||
if (receiverInfo == null || botRoom == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
BotPickUpEvent pickedUpEvent = new BotPickUpEvent(bot, habbo);
|
||||
Emulator.getPluginManager().fireEvent(pickedUpEvent);
|
||||
|
||||
@@ -198,8 +202,8 @@ public class BotManager {
|
||||
return;
|
||||
}
|
||||
|
||||
bot.onPickUp(habbo, receiverInfo.getCurrentRoom());
|
||||
receiverInfo.getCurrentRoom().removeBot(bot);
|
||||
bot.onPickUp(habbo, botRoom);
|
||||
botRoom.removeBot(bot);
|
||||
bot.stopFollowingHabbo();
|
||||
bot.setOwnerId(receiverInfo.getId());
|
||||
bot.setOwnerName(receiverInfo.getUsername());
|
||||
@@ -215,6 +219,14 @@ public class BotManager {
|
||||
}
|
||||
}
|
||||
|
||||
private HabboInfo resolvePickupReceiver(Bot bot, Habbo picker) {
|
||||
if (picker != null && bot.getOwnerId() == picker.getHabboInfo().getId()) {
|
||||
return picker.getHabboInfo();
|
||||
}
|
||||
|
||||
return Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId());
|
||||
}
|
||||
|
||||
public Bot loadBot(ResultSet set) {
|
||||
try {
|
||||
String type = set.getString("type");
|
||||
|
||||
@@ -711,18 +711,22 @@ public class CatalogManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (voucher.isExhausted()) {
|
||||
client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR));
|
||||
return;
|
||||
Voucher.ClaimResult claimResult = voucher.claimForUser(habbo.getHabboInfo().getId());
|
||||
switch (claimResult) {
|
||||
case CLAIMED:
|
||||
break;
|
||||
case EXHAUSTED:
|
||||
client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR));
|
||||
return;
|
||||
case USER_LIMIT:
|
||||
client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher."));
|
||||
return;
|
||||
case FAILED:
|
||||
default:
|
||||
client.sendResponse(new RedeemVoucherErrorComposer(RedeemVoucherErrorComposer.TECHNICAL_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
if (voucher.hasUserExhausted(habbo.getHabboInfo().getId())) {
|
||||
client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher."));
|
||||
return;
|
||||
}
|
||||
|
||||
voucher.addHistoryEntry(habbo.getHabboInfo().getId());
|
||||
|
||||
if (voucher.points > 0) {
|
||||
client.getHabbo().givePoints(voucher.pointsType, voucher.points);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ import java.util.List;
|
||||
public class Voucher {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Voucher.class);
|
||||
|
||||
public enum ClaimResult {
|
||||
CLAIMED,
|
||||
EXHAUSTED,
|
||||
USER_LIMIT,
|
||||
FAILED
|
||||
}
|
||||
|
||||
public final int id;
|
||||
public final String code;
|
||||
public final int credits;
|
||||
@@ -58,18 +65,34 @@ public class Voucher {
|
||||
return this.amount > 0 && this.history.size() >= this.amount;
|
||||
}
|
||||
|
||||
public void addHistoryEntry(int userId) {
|
||||
int timestamp = Emulator.getIntUnixTimestamp();
|
||||
this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp));
|
||||
public synchronized ClaimResult claimForUser(int userId) {
|
||||
if (this.isExhausted()) {
|
||||
return ClaimResult.EXHAUSTED;
|
||||
}
|
||||
|
||||
if (this.hasUserExhausted(userId)) {
|
||||
return ClaimResult.USER_LIMIT;
|
||||
}
|
||||
|
||||
int timestamp = Emulator.getIntUnixTimestamp();
|
||||
if (!this.insertHistoryEntry(userId, timestamp)) {
|
||||
return ClaimResult.FAILED;
|
||||
}
|
||||
|
||||
this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp));
|
||||
return ClaimResult.CLAIMED;
|
||||
}
|
||||
|
||||
private boolean insertHistoryEntry(int userId, int timestamp) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO voucher_history (`voucher_id`, `user_id`, `timestamp`) VALUES (?, ?, ?)")) {
|
||||
statement.setInt(1, this.id);
|
||||
statement.setInt(2, userId);
|
||||
statement.setInt(3, timestamp);
|
||||
|
||||
statement.execute();
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,11 +398,12 @@ public class MarketPlace {
|
||||
synchronized (client.getHabbo().getInventory()) {
|
||||
for (MarketPlaceOffer offer : offers) {
|
||||
if (offer.getState().equals(MarketPlaceState.SOLD)) {
|
||||
client.getHabbo().getInventory().removeMarketplaceOffer(offer);
|
||||
credits += offer.getPrice();
|
||||
removeUser(offer);
|
||||
offer.needsUpdate(true);
|
||||
Emulator.getThreading().run(offer);
|
||||
if (removeUser(offer)) {
|
||||
client.getHabbo().getInventory().removeMarketplaceOffer(offer);
|
||||
credits += offer.getPrice();
|
||||
offer.needsUpdate(true);
|
||||
Emulator.getThreading().run(offer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,13 +417,14 @@ public class MarketPlace {
|
||||
}
|
||||
}
|
||||
|
||||
private static void removeUser(MarketPlaceOffer offer) {
|
||||
private static boolean removeUser(MarketPlaceOffer offer) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE marketplace_items SET user_id = ? WHERE id = ?")) {
|
||||
statement.setInt(1, -1);
|
||||
statement.setInt(2, offer.getOfferId());
|
||||
statement.execute();
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public class CommandsCommand extends Command {
|
||||
|
||||
for (Command c : commands) {
|
||||
String textKey = "commands.description." + c.permission;
|
||||
String commandText = Emulator.getTexts().getValue(textKey, "");
|
||||
String commandText = Emulator.getTexts().getValueQuietly(textKey, "");
|
||||
String commandLine = ":" + c.keys[0];
|
||||
String description = "";
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.eu.habbo.habbohotel.guilds;
|
||||
|
||||
import com.eu.habbo.messages.ClientMessage;
|
||||
import com.eu.habbo.util.PacketGuard;
|
||||
|
||||
public final class GuildBadgeBuilder {
|
||||
public static final int MAX_BADGE_PARTS = 5;
|
||||
private static final int INTS_PER_PART = 3;
|
||||
private static final int BYTES_PER_INT = 4;
|
||||
private static final int MAX_PART_ID = 999;
|
||||
private static final int MAX_COLOR_ID = 99;
|
||||
private static final int MAX_POSITION = 8;
|
||||
|
||||
private GuildBadgeBuilder() {
|
||||
}
|
||||
|
||||
public static String readBadge(ClientMessage packet, int flatPartValueCount) {
|
||||
if (flatPartValueCount % INTS_PER_PART != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int partCount = flatPartValueCount / INTS_PER_PART;
|
||||
if (!PacketGuard.isCountInRange(partCount, 1, MAX_BADGE_PARTS)
|
||||
|| !PacketGuard.hasFixedWidthEntries(packet, flatPartValueCount, BYTES_PER_INT)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder badge = new StringBuilder(partCount * 6);
|
||||
for (int partIndex = 0; partIndex < partCount; partIndex++) {
|
||||
int id = packet.readInt();
|
||||
int color = packet.readInt();
|
||||
int position = packet.readInt();
|
||||
|
||||
if (!isValidPart(id, color, position)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
badge.append(partIndex == 0 ? "b" : "s");
|
||||
badge.append(id < 100 ? "0" : "").append(id < 10 ? "0" : "").append(id);
|
||||
badge.append(color < 10 ? "0" : "").append(color);
|
||||
badge.append(position);
|
||||
}
|
||||
|
||||
return badge.toString();
|
||||
}
|
||||
|
||||
private static boolean isValidPart(int id, int color, int position) {
|
||||
return id >= 0 && id <= MAX_PART_ID
|
||||
&& color >= 0 && color <= MAX_COLOR_ID
|
||||
&& position >= 0 && position <= MAX_POSITION;
|
||||
}
|
||||
}
|
||||
@@ -291,11 +291,12 @@ public class GuildManager {
|
||||
}
|
||||
}
|
||||
} else if (!error) {
|
||||
try (PreparedStatement statement = connection.prepareStatement("UPDATE guilds_members SET level_id = ?, member_since = ? WHERE user_id = ? AND guild_id = ?")) {
|
||||
try (PreparedStatement statement = connection.prepareStatement("UPDATE guilds_members SET level_id = ?, member_since = ? WHERE user_id = ? AND guild_id = ? AND level_id = ?")) {
|
||||
statement.setInt(1, GuildRank.MEMBER.type);
|
||||
statement.setInt(2, Emulator.getIntUnixTimestamp());
|
||||
statement.setInt(3, userId);
|
||||
statement.setInt(4, guild.getId());
|
||||
statement.setInt(5, GuildRank.REQUESTED.type);
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
/**
|
||||
* Builds a complete furnidata entry object (single-line JSON5) from an {@link Item}
|
||||
* (its items_base row) plus a display name/description. Used by the Furni Editor
|
||||
* upsert path when a furni has no furnidata entry yet. Field shape mirrors the
|
||||
* hotel's existing furnidata entries; {@code id} is the item's sprite id so the
|
||||
* renderer resolves the furni's name/data by typeId.
|
||||
*/
|
||||
public final class FurnidataEntryBuilder {
|
||||
|
||||
private FurnidataEntryBuilder() {}
|
||||
|
||||
public static String build(Item item, String name, String description) {
|
||||
String classname = item.getName() != null ? item.getName() : "";
|
||||
String safeName = (name != null && !name.isBlank()) ? name
|
||||
: (item.getFullName() != null && !item.getFullName().isBlank()) ? item.getFullName()
|
||||
: classname;
|
||||
String safeDesc = description != null ? description : "";
|
||||
String customParams = item.getCustomParams() != null ? item.getCustomParams() : "";
|
||||
|
||||
StringBuilder b = new StringBuilder(256);
|
||||
b.append("{\"id\":").append(item.getSpriteId());
|
||||
b.append(",\"classname\":\"").append(esc(classname)).append('"');
|
||||
b.append(",\"revision\":0,\"category\":\"unknown\",\"defaultdir\":0");
|
||||
b.append(",\"xdim\":").append(item.getWidth());
|
||||
b.append(",\"ydim\":").append(item.getLength());
|
||||
b.append(",\"partcolors\":{\"color\":[]}");
|
||||
b.append(",\"name\":\"").append(esc(safeName)).append('"');
|
||||
b.append(",\"description\":\"").append(esc(safeDesc)).append('"');
|
||||
b.append(",\"adurl\":\"\",\"offerid\":-1,\"buyout\":false,\"rentofferid\":-1,\"rentbuyout\":false,\"bc\":false,\"excludeddynamic\":false");
|
||||
b.append(",\"customparams\":\"").append(esc(customParams)).append('"');
|
||||
b.append(",\"specialtype\":1");
|
||||
b.append(",\"canstandon\":").append(item.allowWalk());
|
||||
b.append(",\"cansiton\":").append(item.allowSit());
|
||||
b.append(",\"canlayon\":").append(item.allowLay());
|
||||
b.append('}');
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/** Escape for a JSON string value; collapse control chars to spaces. */
|
||||
private static String esc(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 if (c == '\n' || c == '\r' || c == '\t') b.append(' ');
|
||||
else b.append(c);
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
}
|
||||
@@ -36,30 +36,36 @@ public final class FurnidataSourceResolver {
|
||||
public static Source resolve() {
|
||||
try {
|
||||
String override = Emulator.getConfig().getValue("items.furnidata.path", "");
|
||||
if (!override.isEmpty()) {
|
||||
Path p = Paths.get(override);
|
||||
if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path");
|
||||
return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path does not exist");
|
||||
}
|
||||
|
||||
String rendererConfigPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", "");
|
||||
String assetBasePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
|
||||
|
||||
if (!rendererConfigPath.isEmpty()) {
|
||||
Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath.isEmpty() ? null : Paths.get(assetBasePath));
|
||||
if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer;
|
||||
}
|
||||
|
||||
Source fallback = resolveFromAssetBase(assetBasePath);
|
||||
if (fallback != null) return fallback;
|
||||
|
||||
return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found");
|
||||
return resolveConfigured(override, rendererConfigPath, assetBasePath);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("FurnidataSourceResolver failed", e);
|
||||
return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "Resolver error");
|
||||
}
|
||||
}
|
||||
|
||||
public static Source resolveConfigured(String legacyOverridePath, String rendererConfigPath, String assetBasePath) {
|
||||
if (rendererConfigPath != null && !rendererConfigPath.isEmpty()) {
|
||||
Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath == null || assetBasePath.isEmpty() ? null : Paths.get(assetBasePath));
|
||||
if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer;
|
||||
}
|
||||
|
||||
Source fromAssetBase = resolveFromAssetBase(assetBasePath);
|
||||
if (fromAssetBase != null && fromAssetBase.ok()) return fromAssetBase;
|
||||
|
||||
if (legacyOverridePath != null && !legacyOverridePath.isEmpty()) {
|
||||
Path p = Paths.get(legacyOverridePath);
|
||||
if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path fallback");
|
||||
return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path fallback does not exist");
|
||||
}
|
||||
|
||||
if (fromAssetBase != null) return fromAssetBase;
|
||||
|
||||
return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found");
|
||||
}
|
||||
|
||||
public static Source resolveFromRendererConfig(Path rendererConfig, Path assetBase) {
|
||||
try {
|
||||
if (rendererConfig == null || !Files.exists(rendererConfig)) {
|
||||
|
||||
@@ -56,6 +56,98 @@ public class FurnidataWriter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Outcome of a {@link #create} attempt. */
|
||||
public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR }
|
||||
|
||||
/**
|
||||
* Append a brand-new furnidata entry (upsert's "create" half). Refuses if the
|
||||
* classname already exists (caller should edit instead) or if {@code id} is
|
||||
* already used by a DIFFERENT classname (id collision would break the
|
||||
* {@code roomItem.name.<id>} / typeId resolution on the renderer). The complete
|
||||
* entry object is built by the caller (see FurnidataEntryBuilder) and inserted
|
||||
* right after the opening '[' of the matching section's "furnitype" array.
|
||||
*
|
||||
* @param classname new classname (must be absent from furnidata)
|
||||
* @param id furnidata id (= item sprite id); must not collide
|
||||
* @param type FLOOR -> roomitemtypes, WALL -> wallitemtypes
|
||||
* @param entryJson5 the complete entry object as a single-line JSON5 string
|
||||
* @param createTier split-tier only: the tier dir to write into (e.g. "custom"); ignored for single-file
|
||||
*/
|
||||
public CreateResult create(String classname, int id, FurnitureType type, String entryJson5, String createTier) {
|
||||
String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (cn.isEmpty() || entryJson5 == null || entryJson5.isBlank()) return CreateResult.NO_TARGET;
|
||||
|
||||
// Guard: duplicate classname / id collision (scan the whole source).
|
||||
for (FurnidataEntry e : new FurnidataReader(source, maxBytes).read()) {
|
||||
String ecn = e.classname() == null ? "" : e.classname().trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (ecn.equals(cn)) return CreateResult.ALREADY_EXISTS;
|
||||
if (e.id() == id) return CreateResult.ID_COLLISION;
|
||||
}
|
||||
|
||||
try {
|
||||
Path target = resolveCreateTarget(createTier);
|
||||
if (target == null) return CreateResult.NO_TARGET;
|
||||
|
||||
String raw = Files.readString(target, StandardCharsets.UTF_8);
|
||||
String section = (type == FurnitureType.WALL) ? "wallitemtypes" : "roomitemtypes";
|
||||
int open = furnitypeArrayOpenIndex(raw, section);
|
||||
if (open < 0) return CreateResult.NO_TARGET; // section/array absent in target file
|
||||
|
||||
String edited = raw.substring(0, open) + "\n" + entryJson5 + "," + raw.substring(open);
|
||||
backup(target);
|
||||
atomicWrite(target, edited);
|
||||
return CreateResult.CREATED;
|
||||
} catch (IOException e) {
|
||||
return CreateResult.IO_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-file: the source. Split-tier: the create-tier file (created with a shell if absent). */
|
||||
private Path resolveCreateTarget(String createTier) throws IOException {
|
||||
if (!directory) return source;
|
||||
String tier = (createTier == null || createTier.isBlank()) ? "custom" : createTier.trim();
|
||||
Path base = source.toAbsolutePath().normalize();
|
||||
Path tierDir = safeResolve(base, tier);
|
||||
if (tierDir == null) return null;
|
||||
if (!Files.isDirectory(tierDir)) Files.createDirectories(tierDir);
|
||||
for (String fileName : manifestList(tierDir, "files", List.of())) {
|
||||
Path f = safeResolve(base, tierDir.resolve(fileName).toString());
|
||||
if (f != null && Files.isRegularFile(f)) return f;
|
||||
}
|
||||
Path def = tierDir.resolve("furnidata.json5");
|
||||
if (!Files.exists(def)) {
|
||||
Files.writeString(def,
|
||||
"{\n \"roomitemtypes\": { \"furnitype\": [\n] },\n \"wallitemtypes\": { \"furnitype\": [\n] }\n}\n",
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/** Index just after the '[' that opens {@code <section>.furnitype}, or -1 if absent. String-aware. */
|
||||
static int furnitypeArrayOpenIndex(String raw, String section) {
|
||||
int s = indexOfKey(raw, section, 0);
|
||||
if (s < 0) return -1;
|
||||
int ft = indexOfKey(raw, "furnitype", s);
|
||||
if (ft < 0) return -1;
|
||||
boolean inStr = false; char q = 0;
|
||||
for (int i = ft; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (inStr) { if (c == '\\') i++; else if (c == q) inStr = false; continue; }
|
||||
if (c == '"' || c == '\'') { inStr = true; q = c; }
|
||||
else if (c == '[') return i + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** First occurrence of a quoted key ("key" or 'key') at/after {@code from}, or -1. */
|
||||
private static int indexOfKey(String raw, String key, int from) {
|
||||
int a = raw.indexOf("\"" + key + "\"", from);
|
||||
int b = raw.indexOf("'" + key + "'", from);
|
||||
if (a < 0) return b;
|
||||
if (b < 0) return a;
|
||||
return Math.min(a, b);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -27,6 +27,7 @@ public class FurnitureTextProvider {
|
||||
private final boolean enabled;
|
||||
private volatile Map<String, FurniText> index = Map.of();
|
||||
private volatile Path source;
|
||||
private volatile String sourceDescription = "unknown";
|
||||
private FurnidataWatcher watcher;
|
||||
|
||||
public FurnitureTextProvider(boolean enabled) {
|
||||
@@ -47,7 +48,7 @@ public class FurnitureTextProvider {
|
||||
return;
|
||||
}
|
||||
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||
LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source);
|
||||
LOGGER.info("Furniture Text Provider -> Indexed! ({} names, source: {})", this.index.size(), this.sourceDescription);
|
||||
|
||||
if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) {
|
||||
if (this.watcher != null) this.watcher.stop();
|
||||
@@ -88,9 +89,12 @@ public class FurnitureTextProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private static Path resolveSource() {
|
||||
private Path resolveSource() {
|
||||
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve();
|
||||
if (source.ok()) return source.path();
|
||||
if (source.ok()) {
|
||||
this.sourceDescription = source.message();
|
||||
return source.path();
|
||||
}
|
||||
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message());
|
||||
return null;
|
||||
}
|
||||
|
||||
+8
-1
@@ -3,6 +3,7 @@ package com.eu.habbo.habbohotel.items.interactions;
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
||||
import com.eu.habbo.habbohotel.items.Item;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.habbohotel.rooms.RoomLayout;
|
||||
import com.eu.habbo.habbohotel.rooms.RoomUnit;
|
||||
@@ -133,12 +134,18 @@ public class InteractionRentableSpace extends HabboItem {
|
||||
if (habbo.getHabboStats().isRentingSpace())
|
||||
return;
|
||||
|
||||
if (habbo.getHabboInfo().getCredits() < this.rentCost())
|
||||
int cost = this.rentCost();
|
||||
boolean hasInfiniteCredits = habbo.hasPermission(Permission.ACC_INFINITE_CREDITS);
|
||||
if (!hasInfiniteCredits && habbo.getHabboInfo().getCredits() < cost)
|
||||
return;
|
||||
|
||||
if (habbo.getHabboStats().getClubExpireTimestamp() < Emulator.getIntUnixTimestamp())
|
||||
return;
|
||||
|
||||
if (!hasInfiniteCredits) {
|
||||
habbo.giveCredits(-cost);
|
||||
}
|
||||
|
||||
this.setRenterId(habbo.getHabboInfo().getId());
|
||||
this.setRenterName(habbo.getHabboInfo().getUsername());
|
||||
this.setEndTimestamp(Emulator.getIntUnixTimestamp() + (7 * 86400));
|
||||
|
||||
@@ -58,7 +58,7 @@ public class RoomTrade {
|
||||
public synchronized void offerItem(Habbo habbo, HabboItem item) {
|
||||
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
|
||||
|
||||
if (user.getItems().contains(item))
|
||||
if (user == null || item == null || user.getItems().contains(item))
|
||||
return;
|
||||
|
||||
habbo.getInventory().getItemsComponent().removeHabboItem(item);
|
||||
@@ -71,6 +71,9 @@ public class RoomTrade {
|
||||
public synchronized void offerMultipleItems(Habbo habbo, THashSet<HabboItem> items) {
|
||||
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
|
||||
|
||||
if (user == null || items == null)
|
||||
return;
|
||||
|
||||
for (HabboItem item : items) {
|
||||
if (!user.getItems().contains(item)) {
|
||||
habbo.getInventory().getItemsComponent().removeHabboItem(item);
|
||||
@@ -85,7 +88,7 @@ public class RoomTrade {
|
||||
public synchronized void removeItem(Habbo habbo, HabboItem item) {
|
||||
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
|
||||
|
||||
if (!user.getItems().contains(item))
|
||||
if (user == null || item == null || !user.getItems().contains(item))
|
||||
return;
|
||||
|
||||
habbo.getInventory().getItemsComponent().addItem(item);
|
||||
@@ -98,6 +101,9 @@ public class RoomTrade {
|
||||
public synchronized void accept(Habbo habbo, boolean value) {
|
||||
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
|
||||
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.setAccepted(value);
|
||||
|
||||
this.sendMessageToUsers(new TradeAcceptedComposer(user));
|
||||
@@ -120,6 +126,9 @@ public class RoomTrade {
|
||||
|
||||
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
|
||||
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.confirm();
|
||||
|
||||
this.sendMessageToUsers(new TradeAcceptedComposer(user));
|
||||
@@ -134,6 +143,12 @@ public class RoomTrade {
|
||||
if (this.tradeItems()) {
|
||||
this.closeWindow();
|
||||
this.sendMessageToUsers(new TradeCompleteComposer());
|
||||
} else {
|
||||
this.returnItems();
|
||||
for (RoomTradeUser roomTradeUser : this.users) {
|
||||
roomTradeUser.clearItems();
|
||||
}
|
||||
this.closeWindow();
|
||||
}
|
||||
|
||||
this.room.stopTrade(this);
|
||||
@@ -188,7 +203,6 @@ public class RoomTrade {
|
||||
try (PreparedStatement statement = connection.prepareStatement("UPDATE items SET user_id = ? WHERE id = ? LIMIT 1")) {
|
||||
try (PreparedStatement stmt = connection.prepareStatement("INSERT INTO room_trade_log_items (id, item_id, user_id) VALUES (?, ?, ?)")) {
|
||||
for (HabboItem item : userOne.getItems()) {
|
||||
item.setUserId(userTwoId);
|
||||
statement.setInt(1, userTwoId);
|
||||
statement.setInt(2, item.getId());
|
||||
statement.addBatch();
|
||||
@@ -202,7 +216,6 @@ public class RoomTrade {
|
||||
}
|
||||
|
||||
for (HabboItem item : userTwo.getItems()) {
|
||||
item.setUserId(userOneId);
|
||||
statement.setInt(1, userOneId);
|
||||
statement.setInt(2, item.getId());
|
||||
statement.addBatch();
|
||||
@@ -224,6 +237,16 @@ public class RoomTrade {
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
this.sendMessageToUsers(new TradeClosedComposer(userOne.getHabbo().getRoomUnit().getId(), TradeClosedComposer.ITEMS_NOT_FOUND));
|
||||
return false;
|
||||
}
|
||||
|
||||
for (HabboItem item : userOne.getItems()) {
|
||||
item.setUserId(userTwo.getHabbo().getHabboInfo().getId());
|
||||
}
|
||||
|
||||
for (HabboItem item : userTwo.getItems()) {
|
||||
item.setUserId(userOne.getHabbo().getHabboInfo().getId());
|
||||
}
|
||||
|
||||
THashSet<HabboItem> itemsUserOne = new THashSet<>(userOne.getItems());
|
||||
|
||||
@@ -19,8 +19,13 @@ public class RoomTradeManager {
|
||||
* Starts a trade between two users.
|
||||
*/
|
||||
public void startTrade(Habbo userOne, Habbo userTwo) {
|
||||
RoomTrade trade = new RoomTrade(userOne, userTwo, this.room);
|
||||
RoomTrade trade;
|
||||
synchronized (this.activeTrades) {
|
||||
if (this.hasActiveTrade(userOne) || this.hasActiveTrade(userTwo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
trade = new RoomTrade(userOne, userTwo, this.room);
|
||||
this.activeTrades.add(trade);
|
||||
}
|
||||
|
||||
@@ -58,4 +63,16 @@ public class RoomTradeManager {
|
||||
public THashSet<RoomTrade> getActiveTrades() {
|
||||
return this.activeTrades;
|
||||
}
|
||||
|
||||
private boolean hasActiveTrade(Habbo user) {
|
||||
for (RoomTrade trade : this.activeTrades) {
|
||||
for (RoomTradeUser habbo : trade.getRoomTradeUsers()) {
|
||||
if (habbo.getHabbo() == user) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -90,7 +90,13 @@ public class InfostandBackgroundManager {
|
||||
this.enforce = loaded > 0;
|
||||
|
||||
if (this.enforce) {
|
||||
LOGGER.info("InfostandBackgroundManager -> Loaded {} backgrounds, {} stands, {} overlays, {} cards, {} borders from infostand_backgrounds.",
|
||||
LOGGER.info(summary(
|
||||
this.entries.get(Category.BACKGROUND).size(),
|
||||
this.entries.get(Category.STAND).size(),
|
||||
this.entries.get(Category.OVERLAY).size(),
|
||||
this.entries.get(Category.CARD).size(),
|
||||
this.entries.get(Category.BORDER).size()));
|
||||
LOGGER.debug("Infostand Background Manager assets: {} bg, {} stands, {} overlays, {} cards, {} borders",
|
||||
this.entries.get(Category.BACKGROUND).size(),
|
||||
this.entries.get(Category.STAND).size(),
|
||||
this.entries.get(Category.OVERLAY).size(),
|
||||
@@ -101,6 +107,11 @@ public class InfostandBackgroundManager {
|
||||
}
|
||||
}
|
||||
|
||||
static String summary(int backgrounds, int stands, int overlays, int cards, int borders) {
|
||||
int total = backgrounds + stands + overlays + cards + borders;
|
||||
return String.format("Infostand Background Manager -> Loaded! (%d assets)", total);
|
||||
}
|
||||
|
||||
public boolean canUse(Habbo habbo, Category category, int id) {
|
||||
if (id == 0) return true;
|
||||
if (!this.enforce) return true;
|
||||
|
||||
@@ -83,7 +83,7 @@ public class Incoming {
|
||||
public static final int GuildAcceptMembershipEvent = 3386;
|
||||
public static final int RequestRecylerLogicEvent = 398;
|
||||
public static final int RequestGuildJoinEvent = 998;
|
||||
public static final int RequestCatalogIndexEvent = 2529;
|
||||
public static int RequestCatalogIndexEvent = 2529;
|
||||
public static final int BuildersClubQueryFurniCountEvent = 2529;
|
||||
public static final int BuildersClubPlaceRoomItemEvent = 1051;
|
||||
public static final int BuildersClubPlaceWallItemEvent = 462;
|
||||
|
||||
+30
-18
@@ -35,33 +35,45 @@ public class CatalogAdminCreateOfferEvent extends MessageHandler {
|
||||
int orderNumber = this.packet.readInt();
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
|
||||
CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits,
|
||||
costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack,
|
||||
orderNumber, pageType);
|
||||
if (payload == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId));
|
||||
return;
|
||||
}
|
||||
|
||||
int newId = -1;
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
(pageType == CatalogPageType.BUILDER)
|
||||
(payload.pageType == CatalogPageType.BUILDER)
|
||||
? "INSERT INTO catalog_items_bc (page_id, item_ids, catalog_name, order_number, extradata) VALUES (?, ?, ?, ?, ?)"
|
||||
: "INSERT INTO catalog_items (page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, club_only, extradata, have_offer, offer_id, limited_stack, order_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
Statement.RETURN_GENERATED_KEYS)) {
|
||||
String cleanItemIds = (itemIds == null || itemIds.trim().isEmpty()) ? "0" : itemIds.trim();
|
||||
statement.setInt(1, pageId);
|
||||
statement.setString(2, cleanItemIds);
|
||||
statement.setString(3, catalogName);
|
||||
statement.setInt(1, payload.pageId);
|
||||
statement.setString(2, payload.itemIds);
|
||||
statement.setString(3, payload.catalogName);
|
||||
|
||||
if (pageType == CatalogPageType.BUILDER) {
|
||||
statement.setInt(4, orderNumber);
|
||||
statement.setString(5, extradata);
|
||||
if (payload.pageType == CatalogPageType.BUILDER) {
|
||||
statement.setInt(4, payload.orderNumber);
|
||||
statement.setString(5, payload.extradata);
|
||||
} else {
|
||||
statement.setInt(4, costCredits);
|
||||
statement.setInt(5, costPoints);
|
||||
statement.setInt(6, pointsType);
|
||||
statement.setInt(7, amount);
|
||||
statement.setString(8, clubOnly == 1 ? "1" : "0");
|
||||
statement.setString(9, extradata);
|
||||
statement.setString(10, haveOffer ? "1" : "0");
|
||||
statement.setInt(11, offerIdGroup);
|
||||
statement.setInt(12, limitedStack);
|
||||
statement.setInt(13, orderNumber);
|
||||
statement.setInt(4, payload.costCredits);
|
||||
statement.setInt(5, payload.costPoints);
|
||||
statement.setInt(6, payload.pointsType);
|
||||
statement.setInt(7, payload.amount);
|
||||
statement.setString(8, payload.clubOnly == 1 ? "1" : "0");
|
||||
statement.setString(9, payload.extradata);
|
||||
statement.setString(10, payload.haveOffer ? "1" : "0");
|
||||
statement.setInt(11, payload.offerIdGroup);
|
||||
statement.setInt(12, payload.limitedStack);
|
||||
statement.setInt(13, payload.orderNumber);
|
||||
}
|
||||
statement.execute();
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ public class CatalogAdminCreatePageEvent extends MessageHandler {
|
||||
pageLayout = CatalogPageLayouts.default_3x3;
|
||||
}
|
||||
|
||||
if (parentId != -1 && Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId) == null) {
|
||||
if (parentId != -1 && Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId, pageType) == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + parentId));
|
||||
return;
|
||||
}
|
||||
|
||||
+4
-1
@@ -36,7 +36,10 @@ public class CatalogAdminDeletePageEvent extends MessageHandler {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(query)) {
|
||||
statement.setInt(1, pageId);
|
||||
statement.execute();
|
||||
if (statement.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Emulator.getGameEnvironment().getCatalogManager().getCatalogPagesMap(pageType).remove(pageId);
|
||||
|
||||
+22
-13
@@ -28,12 +28,21 @@ public class CatalogAdminMovePageEvent extends MessageHandler {
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
String tableName = (pageType == CatalogPageType.BUILDER) ? "catalog_pages_bc" : "catalog_pages";
|
||||
|
||||
CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType);
|
||||
if (page == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newParentId == -1) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"UPDATE " + tableName + " SET enabled = IF(enabled = '1', '0', '1') WHERE id = ?")) {
|
||||
statement.setInt(1, pageId);
|
||||
statement.execute();
|
||||
if (statement.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(true, "Page toggled"));
|
||||
return;
|
||||
@@ -44,30 +53,27 @@ public class CatalogAdminMovePageEvent extends MessageHandler {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"UPDATE " + tableName + " SET visible = IF(visible = '1', '0', '1') WHERE id = ?")) {
|
||||
statement.setInt(1, pageId);
|
||||
statement.execute();
|
||||
if (statement.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(true, "Visibility toggled"));
|
||||
return;
|
||||
}
|
||||
|
||||
CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType);
|
||||
if (page == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newParentId == pageId) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "A page cannot be its own parent"));
|
||||
return;
|
||||
}
|
||||
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(newParentId);
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(newParentId, pageType);
|
||||
if (parent == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + newParentId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.wouldCreateCycle(pageId, newParentId)) {
|
||||
if (this.wouldCreateCycle(pageId, newParentId, pageType)) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Refusing to move: that would create a cycle"));
|
||||
return;
|
||||
}
|
||||
@@ -80,18 +86,21 @@ public class CatalogAdminMovePageEvent extends MessageHandler {
|
||||
statement.setInt(1, newParentId);
|
||||
statement.setInt(2, newIndex);
|
||||
statement.setInt(3, pageId);
|
||||
statement.execute();
|
||||
if (statement.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(true, "Page moved"));
|
||||
}
|
||||
|
||||
private boolean wouldCreateCycle(int pageId, int parentId) {
|
||||
private boolean wouldCreateCycle(int pageId, int parentId, CatalogPageType pageType) {
|
||||
int current = parentId;
|
||||
for (int hops = 0; hops < MAX_PARENT_WALK; hops++) {
|
||||
if (current == ROOT_PARENT_ID) return false;
|
||||
if (current == pageId) return true;
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current);
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current, pageType);
|
||||
if (parent == null) return false;
|
||||
current = parent.getParentId();
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
|
||||
|
||||
final class CatalogAdminOfferPayload {
|
||||
private static final int MAX_ITEM_IDS_LENGTH = 512;
|
||||
private static final int MAX_ITEM_IDS = 100;
|
||||
private static final int MAX_CATALOG_NAME_LENGTH = 128;
|
||||
private static final int MAX_EXTRADATA_LENGTH = 1024;
|
||||
private static final int MAX_CURRENCY_VALUE = 1_000_000_000;
|
||||
private static final int MAX_AMOUNT = 10_000;
|
||||
private static final int MAX_POINTS_TYPE = 10_000;
|
||||
private static final int MAX_ORDER_NUMBER = 1_000_000;
|
||||
private static final int MAX_LIMITED_STACK = 1_000_000;
|
||||
|
||||
final int pageId;
|
||||
final String itemIds;
|
||||
final String catalogName;
|
||||
final int costCredits;
|
||||
final int costPoints;
|
||||
final int pointsType;
|
||||
final int amount;
|
||||
final int clubOnly;
|
||||
final String extradata;
|
||||
final boolean haveOffer;
|
||||
final int offerIdGroup;
|
||||
final int limitedStack;
|
||||
final int orderNumber;
|
||||
final CatalogPageType pageType;
|
||||
|
||||
private CatalogAdminOfferPayload(int pageId, String itemIds, String catalogName, int costCredits, int costPoints,
|
||||
int pointsType, int amount, int clubOnly, String extradata, boolean haveOffer,
|
||||
int offerIdGroup, int limitedStack, int orderNumber, CatalogPageType pageType) {
|
||||
this.pageId = pageId;
|
||||
this.itemIds = itemIds;
|
||||
this.catalogName = catalogName;
|
||||
this.costCredits = costCredits;
|
||||
this.costPoints = costPoints;
|
||||
this.pointsType = pointsType;
|
||||
this.amount = amount;
|
||||
this.clubOnly = clubOnly;
|
||||
this.extradata = extradata;
|
||||
this.haveOffer = haveOffer;
|
||||
this.offerIdGroup = offerIdGroup;
|
||||
this.limitedStack = limitedStack;
|
||||
this.orderNumber = orderNumber;
|
||||
this.pageType = pageType;
|
||||
}
|
||||
|
||||
static CatalogAdminOfferPayload validate(int pageId, String itemIds, String catalogName, int costCredits,
|
||||
int costPoints, int pointsType, int amount, int clubOnly,
|
||||
String extradata, boolean haveOffer, int offerIdGroup,
|
||||
int limitedStack, int orderNumber, CatalogPageType pageType) {
|
||||
String cleanItemIds = normalizeItemIds(itemIds);
|
||||
String cleanCatalogName = clamp(catalogName, MAX_CATALOG_NAME_LENGTH);
|
||||
String cleanExtradata = clamp(extradata, MAX_EXTRADATA_LENGTH);
|
||||
|
||||
if (pageId <= 0
|
||||
|| cleanItemIds == null
|
||||
|| cleanCatalogName.isBlank()
|
||||
|| !isInRange(orderNumber, 0, MAX_ORDER_NUMBER)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pageType != CatalogPageType.BUILDER) {
|
||||
if (!isInRange(costCredits, 0, MAX_CURRENCY_VALUE)
|
||||
|| !isInRange(costPoints, 0, MAX_CURRENCY_VALUE)
|
||||
|| !isInRange(pointsType, 0, MAX_POINTS_TYPE)
|
||||
|| !isInRange(amount, 1, MAX_AMOUNT)
|
||||
|| !isInRange(clubOnly, 0, 1)
|
||||
|| offerIdGroup < 0
|
||||
|| !isInRange(limitedStack, 0, MAX_LIMITED_STACK)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new CatalogAdminOfferPayload(pageId, cleanItemIds, cleanCatalogName, costCredits, costPoints,
|
||||
pointsType, amount, clubOnly, cleanExtradata, haveOffer, offerIdGroup, limitedStack, orderNumber,
|
||||
pageType);
|
||||
}
|
||||
|
||||
private static String normalizeItemIds(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
String clean = value.trim();
|
||||
if (clean.length() > MAX_ITEM_IDS_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] parts = clean.split(",");
|
||||
if (parts.length == 0 || parts.length > MAX_ITEM_IDS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (String part : parts) {
|
||||
if (part.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Integer.parseInt(part.trim()) < 0) {
|
||||
return null;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return clean.replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
private static boolean isInRange(int value, int min, int max) {
|
||||
return value >= min && value <= max;
|
||||
}
|
||||
|
||||
private static String clamp(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
+39
-18
@@ -34,10 +34,28 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler {
|
||||
int orderNumber = this.packet.readInt();
|
||||
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
|
||||
|
||||
if (offerId <= 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer id"));
|
||||
return;
|
||||
}
|
||||
|
||||
CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits,
|
||||
costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack,
|
||||
orderNumber, pageType);
|
||||
if (payload == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId));
|
||||
return;
|
||||
}
|
||||
|
||||
boolean updateItemIds = itemIds != null && !itemIds.trim().isEmpty();
|
||||
|
||||
String sql;
|
||||
if (pageType == CatalogPageType.BUILDER) {
|
||||
if (payload.pageType == CatalogPageType.BUILDER) {
|
||||
sql = updateItemIds
|
||||
? "UPDATE catalog_items_bc SET page_id = ?, item_ids = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?"
|
||||
: "UPDATE catalog_items_bc SET page_id = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?";
|
||||
@@ -50,30 +68,33 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
int idx = 1;
|
||||
statement.setInt(idx++, pageId);
|
||||
statement.setInt(idx++, payload.pageId);
|
||||
if (updateItemIds) {
|
||||
statement.setString(idx++, itemIds.trim());
|
||||
statement.setString(idx++, payload.itemIds);
|
||||
}
|
||||
statement.setString(idx++, catalogName);
|
||||
statement.setString(idx++, payload.catalogName);
|
||||
|
||||
if (pageType == CatalogPageType.BUILDER) {
|
||||
statement.setInt(idx++, orderNumber);
|
||||
statement.setString(idx++, extradata);
|
||||
if (payload.pageType == CatalogPageType.BUILDER) {
|
||||
statement.setInt(idx++, payload.orderNumber);
|
||||
statement.setString(idx++, payload.extradata);
|
||||
statement.setInt(idx, offerId);
|
||||
} else {
|
||||
statement.setInt(idx++, costCredits);
|
||||
statement.setInt(idx++, costPoints);
|
||||
statement.setInt(idx++, pointsType);
|
||||
statement.setInt(idx++, amount);
|
||||
statement.setString(idx++, clubOnly == 1 ? "1" : "0");
|
||||
statement.setString(idx++, extradata);
|
||||
statement.setString(idx++, haveOffer ? "1" : "0");
|
||||
statement.setInt(idx++, offerIdGroup);
|
||||
statement.setInt(idx++, limitedStack);
|
||||
statement.setInt(idx++, orderNumber);
|
||||
statement.setInt(idx++, payload.costCredits);
|
||||
statement.setInt(idx++, payload.costPoints);
|
||||
statement.setInt(idx++, payload.pointsType);
|
||||
statement.setInt(idx++, payload.amount);
|
||||
statement.setString(idx++, payload.clubOnly == 1 ? "1" : "0");
|
||||
statement.setString(idx++, payload.extradata);
|
||||
statement.setString(idx++, payload.haveOffer ? "1" : "0");
|
||||
statement.setInt(idx++, payload.offerIdGroup);
|
||||
statement.setInt(idx++, payload.limitedStack);
|
||||
statement.setInt(idx++, payload.orderNumber);
|
||||
statement.setInt(idx, offerId);
|
||||
}
|
||||
statement.execute();
|
||||
if (statement.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Offer not found: " + offerId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(true, "Offer saved"));
|
||||
|
||||
+8
-5
@@ -74,13 +74,13 @@ public class CatalogAdminSavePageEvent extends MessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId);
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId, pageType);
|
||||
if (parent == null) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + parentId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.wouldCreateCycle(pageId, parentId)) {
|
||||
if (this.wouldCreateCycle(pageId, parentId, pageType)) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Refusing to re-parent: that would create a cycle"));
|
||||
return;
|
||||
}
|
||||
@@ -144,18 +144,21 @@ public class CatalogAdminSavePageEvent extends MessageHandler {
|
||||
statement.setInt(15, pageId);
|
||||
}
|
||||
|
||||
statement.execute();
|
||||
if (statement.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.client.sendResponse(new CatalogAdminResultComposer(true, "Page saved"));
|
||||
}
|
||||
|
||||
private boolean wouldCreateCycle(int pageId, int parentId) {
|
||||
private boolean wouldCreateCycle(int pageId, int parentId, CatalogPageType pageType) {
|
||||
int current = parentId;
|
||||
for (int hops = 0; hops < MAX_PARENT_WALK; hops++) {
|
||||
if (current == ROOT_PARENT_ID) return false;
|
||||
if (current == pageId) return true;
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current);
|
||||
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current, pageType);
|
||||
if (parent == null) return false;
|
||||
current = parent.getParentId();
|
||||
}
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@ public class FurniEditorDeleteEvent extends MessageHandler {
|
||||
// Check catalog_items references
|
||||
int catalogCount = 0;
|
||||
try (PreparedStatement stmt = connection.prepareStatement(
|
||||
"SELECT COUNT(*) FROM catalog_items WHERE item_ids LIKE ?")) {
|
||||
stmt.setString(1, "%" + id + "%");
|
||||
"SELECT COUNT(*) FROM catalog_items WHERE " + FurniEditorHelper.catalogItemIdsTokenSql("item_ids"))) {
|
||||
stmt.setString(1, FurniEditorHelper.catalogItemIdsTokenPattern(id));
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
catalogCount = rs.getInt(1);
|
||||
|
||||
+2
-2
@@ -75,8 +75,8 @@ public class FurniEditorDetailEvent extends MessageHandler {
|
||||
"ci.page_id AS ci_page_id, COALESCE(cp.caption, '') AS page_caption " +
|
||||
"FROM catalog_items ci " +
|
||||
"LEFT JOIN catalog_pages cp ON ci.page_id = cp.id " +
|
||||
"WHERE ci.item_ids LIKE ?")) {
|
||||
stmt.setString(1, "%" + itemId + "%");
|
||||
"WHERE " + FurniEditorHelper.catalogItemIdsTokenSql("ci.item_ids"))) {
|
||||
stmt.setString(1, FurniEditorHelper.catalogItemIdsTokenPattern(itemId));
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
catalogItems.add(FurniEditorHelper.readCatalogRef(rs));
|
||||
|
||||
+9
@@ -11,6 +11,15 @@ import java.util.Map;
|
||||
* FurniEditorSearchEvent to ensure consistent field reading.
|
||||
*/
|
||||
public class FurniEditorHelper {
|
||||
public static final String CATALOG_ITEM_IDS_TOKEN_SQL = "CONCAT(',', REPLACE(item_ids, ' ', ''), ',') LIKE ?";
|
||||
|
||||
public static String catalogItemIdsTokenSql(String column) {
|
||||
return "CONCAT(',', REPLACE(" + column + ", ' ', ''), ',') LIKE ?";
|
||||
}
|
||||
|
||||
public static String catalogItemIdsTokenPattern(int itemId) {
|
||||
return "%," + itemId + ",%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the 14 base fields from items_base into a Map.
|
||||
|
||||
+9
-49
@@ -4,15 +4,11 @@ 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.FurniEditorResultComposer;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class FurniEditorUpdateEvent extends MessageHandler {
|
||||
|
||||
@@ -39,57 +35,18 @@ public class FurniEditorUpdateEvent extends MessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.size() == 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No fields to update"));
|
||||
FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(json);
|
||||
if (!payload.valid()) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, payload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE with whitelisted fields
|
||||
StringBuilder setClauses = new StringBuilder();
|
||||
List<Object> values = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, JsonElement> entry : json.entrySet()) {
|
||||
String jsKey = entry.getKey();
|
||||
String dbColumn = FurniEditorHelper.FIELD_MAP.get(jsKey);
|
||||
|
||||
if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) {
|
||||
continue; // Skip unknown or disallowed fields
|
||||
}
|
||||
|
||||
if (setClauses.length() > 0) setClauses.append(", ");
|
||||
setClauses.append("`").append(dbColumn).append("` = ?");
|
||||
|
||||
JsonElement val = entry.getValue();
|
||||
if (val.isJsonPrimitive()) {
|
||||
if (val.getAsJsonPrimitive().isBoolean()) {
|
||||
values.add(val.getAsBoolean() ? "1" : "0");
|
||||
} else if (val.getAsJsonPrimitive().isNumber()) {
|
||||
// Check if it's a decimal number
|
||||
String numStr = val.getAsString();
|
||||
if (numStr.contains(".")) {
|
||||
values.add(val.getAsDouble());
|
||||
} else {
|
||||
values.add(val.getAsInt());
|
||||
}
|
||||
} else {
|
||||
values.add(val.getAsString());
|
||||
}
|
||||
} else {
|
||||
values.add(val.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (setClauses.length() == 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "No valid fields to update"));
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "UPDATE items_base SET " + setClauses + " WHERE id = ?";
|
||||
String sql = "UPDATE items_base SET " + payload.setClauses + " WHERE id = ?";
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
int idx = 1;
|
||||
for (Object value : values) {
|
||||
for (Object value : payload.values) {
|
||||
if (value instanceof Integer) {
|
||||
stmt.setInt(idx++, (Integer) value);
|
||||
} else if (value instanceof Double) {
|
||||
@@ -99,7 +56,10 @@ public class FurniEditorUpdateEvent extends MessageHandler {
|
||||
}
|
||||
}
|
||||
stmt.setInt(idx, id);
|
||||
stmt.executeUpdate();
|
||||
if (stmt.executeUpdate() == 0) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found: " + id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload emulator item definitions
|
||||
|
||||
+35
-3
@@ -5,6 +5,8 @@ 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.items.Item;
|
||||
import com.eu.habbo.habbohotel.items.FurnidataEntryBuilder;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
@@ -109,6 +111,7 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler {
|
||||
String safeDesc = (description != null) ? description : "";
|
||||
|
||||
boolean written;
|
||||
boolean created = false;
|
||||
List<FurnidataEntry> delta;
|
||||
|
||||
FurnidataLock.LOCK.lock();
|
||||
@@ -121,8 +124,37 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler {
|
||||
);
|
||||
written = writer.write(classname, safeName, safeDesc);
|
||||
if (!written) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata"));
|
||||
return;
|
||||
// Upsert: no furnidata entry for this classname yet → create a
|
||||
// complete one seeded from items_base (id = sprite id).
|
||||
Item item = Emulator.getGameEnvironment().getItemManager().getItem(itemId);
|
||||
if (item == null) {
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
|
||||
return;
|
||||
}
|
||||
String createTier = Emulator.getConfig().getValue("items.furnidata.create_tier", "custom");
|
||||
String entry = FurnidataEntryBuilder.build(
|
||||
item,
|
||||
FurnitureTextProvider.sanitize(safeName),
|
||||
FurnitureTextProvider.sanitize(safeDesc));
|
||||
FurnidataWriter.CreateResult cr =
|
||||
writer.create(item.getName(), item.getSpriteId(), item.getType(), entry, createTier);
|
||||
switch (cr) {
|
||||
case CREATED:
|
||||
created = true;
|
||||
written = true;
|
||||
break;
|
||||
case ALREADY_EXISTS:
|
||||
// entry already present (race / no-op edit) — apply the edit and treat as success
|
||||
writer.write(classname, safeName, safeDesc);
|
||||
written = true;
|
||||
break;
|
||||
case ID_COLLISION:
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Sprite id already used by another classname"));
|
||||
return;
|
||||
default:
|
||||
this.client.sendResponse(new FurniEditorResultComposer(false, "Failed to create furnidata entry"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
delta = provider.reindexFromSource();
|
||||
@@ -161,7 +193,7 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler {
|
||||
FurnidataAuditLog.record(
|
||||
adminId,
|
||||
classname,
|
||||
"edit",
|
||||
created ? "create" : "edit",
|
||||
oldName != null ? oldName : "",
|
||||
FurnitureTextProvider.sanitize(safeName),
|
||||
oldDesc,
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class FurniEditorUpdatePayload {
|
||||
public final String setClauses;
|
||||
public final List<Object> values;
|
||||
public final String error;
|
||||
|
||||
private FurniEditorUpdatePayload(String setClauses, List<Object> values, String error) {
|
||||
this.setClauses = setClauses;
|
||||
this.values = values;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public static FurniEditorUpdatePayload validate(JsonObject json) {
|
||||
if (json == null || json.size() == 0) {
|
||||
return invalid("No fields to update");
|
||||
}
|
||||
|
||||
StringBuilder setClauses = new StringBuilder();
|
||||
List<Object> values = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, JsonElement> entry : json.entrySet()) {
|
||||
String dbColumn = FurniEditorHelper.FIELD_MAP.get(entry.getKey());
|
||||
if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object value = validateValue(dbColumn, entry.getValue());
|
||||
if (value == null) {
|
||||
return invalid("Invalid value for " + entry.getKey());
|
||||
}
|
||||
|
||||
if (setClauses.length() > 0) setClauses.append(", ");
|
||||
setClauses.append("`").append(dbColumn).append("` = ?");
|
||||
values.add(value);
|
||||
}
|
||||
|
||||
if (setClauses.length() == 0) {
|
||||
return invalid("No valid fields to update");
|
||||
}
|
||||
|
||||
return new FurniEditorUpdatePayload(setClauses.toString(), values, null);
|
||||
}
|
||||
|
||||
public boolean valid() {
|
||||
return this.error == null;
|
||||
}
|
||||
|
||||
private static FurniEditorUpdatePayload invalid(String error) {
|
||||
return new FurniEditorUpdatePayload("", List.of(), error);
|
||||
}
|
||||
|
||||
private static Object validateValue(String dbColumn, JsonElement element) {
|
||||
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonPrimitive primitive = element.getAsJsonPrimitive();
|
||||
return switch (dbColumn) {
|
||||
case "public_name" -> boundedString(primitive, 0, 56);
|
||||
case "type" -> itemType(primitive);
|
||||
case "width", "length" -> boundedInt(primitive, 0, 64);
|
||||
case "stack_height" -> boundedDouble(primitive, 0.0D, 99.99D);
|
||||
case "allow_stack", "allow_walk", "allow_sit", "allow_lay", "allow_gift",
|
||||
"allow_trade", "allow_recycle", "allow_marketplace_sell", "allow_inventory_stack" -> booleanFlag(primitive);
|
||||
case "interaction_type" -> boundedString(primitive, 0, 500);
|
||||
case "interaction_modes_count" -> boundedInt(primitive, 0, 100);
|
||||
case "vending_ids", "clothing_on_walk" -> boundedString(primitive, 0, 255);
|
||||
case "customparams" -> boundedString(primitive, 0, 256);
|
||||
case "multiheight" -> boundedString(primitive, 0, 50);
|
||||
case "effect_id_male", "effect_id_female", "sprite_id" -> boundedInt(primitive, 0, Integer.MAX_VALUE);
|
||||
case "description" -> boundedString(primitive, 0, 500);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static String boundedString(JsonPrimitive primitive, int minLength, int maxLength) {
|
||||
if (!primitive.isString()) return null;
|
||||
String value = primitive.getAsString();
|
||||
if (value.length() < minLength || value.length() > maxLength) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String itemType(JsonPrimitive primitive) {
|
||||
String value = boundedString(primitive, 1, 3);
|
||||
if (value == null) return null;
|
||||
return value.matches("[a-z]+") ? value : null;
|
||||
}
|
||||
|
||||
private static Integer boundedInt(JsonPrimitive primitive, int min, int max) {
|
||||
try {
|
||||
int value = primitive.getAsInt();
|
||||
return value >= min && value <= max ? value : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Double boundedDouble(JsonPrimitive primitive, double min, double max) {
|
||||
try {
|
||||
double value = primitive.getAsDouble();
|
||||
return Double.isFinite(value) && value >= min && value <= max ? value : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String booleanFlag(JsonPrimitive primitive) {
|
||||
if (primitive.isBoolean()) {
|
||||
return primitive.getAsBoolean() ? "1" : "0";
|
||||
}
|
||||
|
||||
if (primitive.isNumber()) {
|
||||
int value = primitive.getAsInt();
|
||||
return value == 0 || value == 1 ? String.valueOf(value) : null;
|
||||
}
|
||||
|
||||
if (primitive.isString()) {
|
||||
String value = primitive.getAsString();
|
||||
return "0".equals(value) || "1".equals(value) ? value : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+3
-19
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.guilds;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.guilds.Guild;
|
||||
import com.eu.habbo.habbohotel.guilds.GuildBadgeBuilder;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
@@ -27,25 +28,8 @@ public class GuildChangeBadgeEvent extends MessageHandler {
|
||||
|
||||
int count = this.packet.readInt();
|
||||
|
||||
String badge = "";
|
||||
|
||||
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 += "b";
|
||||
} else {
|
||||
badge += "s";
|
||||
}
|
||||
|
||||
badge += (id < 100 ? "0" : "") + (id < 10 ? "0" : "") + id + (color < 10 ? "0" : "") + color + "" + pos;
|
||||
|
||||
base += 3;
|
||||
}
|
||||
String badge = GuildBadgeBuilder.readBadge(this.packet, count);
|
||||
if (badge == null) return;
|
||||
|
||||
if (guild.getBadge().equalsIgnoreCase(badge))
|
||||
return;
|
||||
|
||||
+6
-19
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.guilds;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.guilds.Guild;
|
||||
import com.eu.habbo.habbohotel.guilds.GuildBadgeBuilder;
|
||||
import com.eu.habbo.habbohotel.modtool.ScripterManager;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
@@ -69,24 +70,10 @@ public class RequestGuildBuyEvent extends MessageHandler {
|
||||
|
||||
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;
|
||||
String badge = GuildBadgeBuilder.readBadge(this.packet, count);
|
||||
if (badge == null) {
|
||||
this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only charge the player once every step has been validated. Previously the
|
||||
@@ -103,7 +90,7 @@ public class RequestGuildBuyEvent extends MessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
Guild guild = Emulator.getGameEnvironment().getGuildManager().createGuild(this.client.getHabbo(), roomId, r.getName(), name, description, badge.toString(), colorOne, colorTwo);
|
||||
Guild guild = Emulator.getGameEnvironment().getGuildManager().createGuild(this.client.getHabbo(), roomId, r.getName(), name, description, badge, colorOne, colorTwo);
|
||||
|
||||
r.setGuild(guild.getId());
|
||||
r.removeAllRights();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.polls;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.polls.Poll;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.habbohotel.users.HabboBadge;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer;
|
||||
@@ -31,12 +32,20 @@ public class AnswerPollEvent extends MessageHandler {
|
||||
if(answer.length() <= 0) return;
|
||||
|
||||
if (pollId == 0 && questionId <= 0) {
|
||||
this.client.getHabbo().getHabboInfo().getCurrentRoom().handleWordQuiz(this.client.getHabbo(), answer.toString());
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room != null) {
|
||||
room.handleWordQuiz(this.client.getHabbo(), answer.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
answer = new StringBuilder(answer.substring(1));
|
||||
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room == null || room.getPollId() != pollId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Poll poll = Emulator.getGameEnvironment().getPollManager().getPoll(pollId);
|
||||
|
||||
if (poll != null) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.polls;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.polls.Poll;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -17,6 +18,10 @@ public class CancelPollEvent extends MessageHandler {
|
||||
public void handle() throws Exception {
|
||||
int pollId = this.packet.readInt();
|
||||
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room == null || room.getPollId() != pollId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Poll poll = Emulator.getGameEnvironment().getPollManager().getPoll(pollId);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.polls;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.polls.Poll;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.polls.PollQuestionsComposer;
|
||||
|
||||
@@ -10,6 +11,11 @@ public class GetPollDataEvent extends MessageHandler {
|
||||
public void handle() throws Exception {
|
||||
int pollId = this.packet.readInt();
|
||||
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room == null || room.getPollId() != pollId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Poll poll = Emulator.getGameEnvironment().getPollManager().getPoll(pollId);
|
||||
|
||||
if (poll != null) {
|
||||
|
||||
+15
-8
@@ -36,6 +36,10 @@ public class RedeemClothingEvent extends MessageHandler {
|
||||
|
||||
if (clothing != null) {
|
||||
if (!this.client.getHabbo().getInventory().getWardrobeComponent().getClothing().contains(clothing.id)) {
|
||||
if (!this.grantClothing(clothing.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.setRoomId(0);
|
||||
RoomTile tile = this.client.getHabbo().getHabboInfo().getCurrentRoom().getLayout().getTile(item.getX(), item.getY());
|
||||
this.client.getHabbo().getHabboInfo().getCurrentRoom().removeHabboItem(item);
|
||||
@@ -44,14 +48,6 @@ public class RedeemClothingEvent extends MessageHandler {
|
||||
this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RemoveFloorItemComposer(item, true).compose());
|
||||
Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId()));
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) {
|
||||
statement.setInt(1, this.client.getHabbo().getHabboInfo().getId());
|
||||
statement.setInt(2, clothing.id);
|
||||
statement.execute();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
}
|
||||
|
||||
this.client.getHabbo().getInventory().getWardrobeComponent().getClothing().add(clothing.id);
|
||||
this.client.getHabbo().getInventory().getWardrobeComponent().getClothingSets().addAll(clothing.setId);
|
||||
this.client.sendResponse(new UserClothesComposer(this.client.getHabbo()));
|
||||
@@ -67,4 +63,15 @@ public class RedeemClothingEvent extends MessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean grantClothing(int clothingId) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) {
|
||||
statement.setInt(1, this.client.getHabbo().getHabboInfo().getId());
|
||||
statement.setInt(2, clothingId);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -103,6 +103,10 @@ public class ToggleFloorItemEvent extends MessageHandler {
|
||||
|
||||
// Do not move to onClick(). Wired could trigger it.
|
||||
if (item instanceof InteractionMonsterPlantSeed) {
|
||||
if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId()));
|
||||
|
||||
boolean isRare = item.getBaseItem().getName().contains("rare");
|
||||
|
||||
+15
-2
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.rooms.users;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.rooms.RoomManager;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
|
||||
public class RoomUserBanEvent extends MessageHandler {
|
||||
@@ -11,6 +12,18 @@ public class RoomUserBanEvent extends MessageHandler {
|
||||
int roomId = this.packet.readInt();
|
||||
String banName = this.packet.readString();
|
||||
|
||||
Emulator.getGameEnvironment().getRoomManager().banUserFromRoom(this.client.getHabbo(), userId, roomId, RoomManager.RoomBanTypes.valueOf(banName));
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room == null || room.getId() != roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
RoomManager.RoomBanTypes banType;
|
||||
try {
|
||||
banType = RoomManager.RoomBanTypes.valueOf(banName);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
Emulator.getGameEnvironment().getRoomManager().banUserFromRoom(this.client.getHabbo(), userId, roomId, banType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -15,17 +15,18 @@ public class RoomUserMuteEvent extends MessageHandler {
|
||||
int roomId = this.packet.readInt();
|
||||
int minutes = this.packet.readInt();
|
||||
|
||||
Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId);
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room == null || room.getId() != roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (room != null) {
|
||||
if (room.hasRights(this.client.getHabbo()) || this.client.getHabbo().hasPermission("cmd_mute") || this.client.getHabbo().hasPermission(Permission.ACC_AMBASSADOR)) {
|
||||
Habbo habbo = room.getHabbo(userId);
|
||||
if (room.hasRights(this.client.getHabbo()) || this.client.getHabbo().hasPermission("cmd_mute") || this.client.getHabbo().hasPermission(Permission.ACC_AMBASSADOR)) {
|
||||
Habbo habbo = room.getHabbo(userId);
|
||||
|
||||
if (habbo != null) {
|
||||
room.muteHabbo(habbo, minutes);
|
||||
habbo.getClient().sendResponse(new MutedWhisperComposer(minutes * 60));
|
||||
AchievementManager.progressAchievement(this.client.getHabbo(), Emulator.getGameEnvironment().getAchievementManager().getAchievement("SelfModMuteSeen"));
|
||||
}
|
||||
if (habbo != null) {
|
||||
room.muteHabbo(habbo, minutes);
|
||||
habbo.getClient().sendResponse(new MutedWhisperComposer(minutes * 60));
|
||||
AchievementManager.progressAchievement(this.client.getHabbo(), Emulator.getGameEnvironment().getAchievementManager().getAchievement("SelfModMuteSeen"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -3,8 +3,12 @@ package com.eu.habbo.messages.incoming.rooms.users;
|
||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||
import com.eu.habbo.habbohotel.rooms.Room;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.util.PacketGuard;
|
||||
|
||||
public class RoomUserRemoveRightsEvent extends MessageHandler {
|
||||
private static final int MAX_RIGHTS_REMOVALS = 100;
|
||||
private static final int BYTES_PER_USER_ID = 4;
|
||||
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
int amount = this.packet.readInt();
|
||||
@@ -15,6 +19,11 @@ public class RoomUserRemoveRightsEvent extends MessageHandler {
|
||||
return;
|
||||
|
||||
if (room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) {
|
||||
if (!PacketGuard.isCountInRange(amount, 1, MAX_RIGHTS_REMOVALS)
|
||||
|| !PacketGuard.hasFixedWidthEntries(this.packet, amount, BYTES_PER_USER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < amount; i++) {
|
||||
int userId = this.packet.readInt();
|
||||
|
||||
|
||||
+6
-6
@@ -10,13 +10,13 @@ public class UnbanRoomUserEvent extends MessageHandler {
|
||||
int userId = this.packet.readInt();
|
||||
int roomId = this.packet.readInt();
|
||||
|
||||
Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId);
|
||||
|
||||
if (room != null) {
|
||||
if (room.isOwner(this.client.getHabbo())) {
|
||||
room.unbanHabbo(userId);
|
||||
}
|
||||
Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
|
||||
if (room == null || room.getId() != roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.isOwner(this.client.getHabbo())) {
|
||||
room.unbanHabbo(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ public class Outgoing {
|
||||
public final static int WiredOpenComposer = 1830;
|
||||
public final static int UnknownCatalogPageOfferComposer = 1889;
|
||||
public final static int NuxAlertComposer = 2023;
|
||||
public final static int InClientLinkComposer = 2023;
|
||||
public static int InClientLinkComposer = NuxAlertComposer;
|
||||
public final static int HotelViewExpiringCatalogPageCommposer = 2515;
|
||||
public final static int UnknownHabboWayQuizComposer = 2772;
|
||||
public final static int PetLevelUpdatedComposer = 2824;
|
||||
|
||||
+2
-2
@@ -23,10 +23,10 @@ public class AvailableCommandsComposer extends MessageComposer {
|
||||
for (Command cmd : this.commands) {
|
||||
this.response.appendString(cmd.keys[0]);
|
||||
this.response.appendString(
|
||||
Emulator.getTexts().getValue("commands.description." + cmd.permission, cmd.permission)
|
||||
Emulator.getTexts().getValueQuietly("commands.description." + cmd.permission, cmd.permission)
|
||||
);
|
||||
}
|
||||
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +85,10 @@ public abstract class Server {
|
||||
}
|
||||
|
||||
if (!channelFuture.isSuccess()) {
|
||||
LOGGER.info("Failed to connect to the host ({}:{})@{}", this.host, this.port, this.name);
|
||||
LOGGER.info("Failed to start {} on {}:{}", this.name, this.host, this.port);
|
||||
System.exit(0);
|
||||
} else {
|
||||
LOGGER.info("Started GameServer on {}:{}@{}", this.host, this.port, this.name);
|
||||
LOGGER.info("Started {} on {}:{}", this.name, this.host, this.port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public abstract class Server {
|
||||
} catch(InterruptedException e) {
|
||||
LOGGER.error("Exception during {} shutdown... HARD STOP", this.name, e);
|
||||
}
|
||||
LOGGER.info("GameServer Stopped!");
|
||||
LOGGER.info("Stopped {}", this.name);
|
||||
}
|
||||
|
||||
public ServerBootstrap getServerBootstrap() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eu.habbo.util;
|
||||
|
||||
import com.eu.habbo.messages.ClientMessage;
|
||||
|
||||
public final class PacketGuard {
|
||||
private PacketGuard() {
|
||||
}
|
||||
|
||||
public static boolean isCountInRange(int count, int min, int max) {
|
||||
return count >= min && count <= max;
|
||||
}
|
||||
|
||||
public static boolean hasReadableBytes(ClientMessage packet, int requiredBytes) {
|
||||
return packet != null && requiredBytes >= 0 && packet.bytesAvailable() >= requiredBytes;
|
||||
}
|
||||
|
||||
public static boolean hasFixedWidthEntries(ClientMessage packet, int entryCount, int bytesPerEntry) {
|
||||
if (packet == null || entryCount < 0 || bytesPerEntry < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long requiredBytes = (long) entryCount * bytesPerEntry;
|
||||
return requiredBytes <= Integer.MAX_VALUE && packet.bytesAvailable() >= requiredBytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.eu.habbo.util.logback;
|
||||
|
||||
import ch.qos.logback.classic.pattern.ClassicConverter;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
|
||||
public class ConsoleLevelConverter extends ClassicConverter {
|
||||
@Override
|
||||
public String convert(ILoggingEvent event) {
|
||||
return ConsoleStyle.level(event == null ? null : event.getLevel(), ConsoleStyle.isRuntimeEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.eu.habbo.util.logback;
|
||||
|
||||
import ch.qos.logback.classic.pattern.ClassicConverter;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
|
||||
public class ConsoleLoggerConverter extends ClassicConverter {
|
||||
@Override
|
||||
public String convert(ILoggingEvent event) {
|
||||
return ConsoleStyle.logger(event == null ? "" : event.getLoggerName(), ConsoleStyle.isRuntimeEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.eu.habbo.util.logback;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ConsoleStyle {
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
private static final String ANSI_BOLD = "\u001B[1m";
|
||||
private static final String ANSI_DIM = "\u001B[2m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_YELLOW = "\u001B[33m";
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
|
||||
private static final int LOGGER_WIDTH = 22;
|
||||
|
||||
private ConsoleStyle() {
|
||||
}
|
||||
|
||||
public static boolean isRuntimeEnabled() {
|
||||
return isEnabled(
|
||||
System.getenv(),
|
||||
System.console() != null,
|
||||
System.getProperty("os.name", "Unknown"),
|
||||
System.getProperty("habbo.console.style", "auto"));
|
||||
}
|
||||
|
||||
public static boolean isEnabled(Map<String, String> environment, boolean interactiveConsole, String osName, String styleProperty) {
|
||||
String style = styleProperty == null ? "auto" : styleProperty.trim().toLowerCase(Locale.ROOT);
|
||||
if (style.equals("ansi") || style.equals("color") || style.equals("colours") || style.equals("colors")) {
|
||||
return true;
|
||||
}
|
||||
if (style.equals("plain") || style.equals("none") || style.equals("false") || style.equals("off")) {
|
||||
return false;
|
||||
}
|
||||
if (!interactiveConsole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, String> env = environment == null ? Collections.emptyMap() : environment;
|
||||
if (env.containsKey("NO_COLOR")) {
|
||||
return false;
|
||||
}
|
||||
if (env.containsKey("WT_SESSION") || env.containsKey("ANSICON") || "ON".equalsIgnoreCase(env.get("ConEmuANSI"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String term = env.getOrDefault("TERM", "");
|
||||
if (term.equalsIgnoreCase("dumb")) {
|
||||
return false;
|
||||
}
|
||||
if (!term.isBlank() && (term.contains("xterm") || term.contains("ansi") || term.contains("screen") || term.contains("tmux"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return osName == null || !osName.toLowerCase(Locale.ROOT).startsWith("windows");
|
||||
}
|
||||
|
||||
public static String level(Level level, boolean styled) {
|
||||
String name = level == null ? "INFO" : level.toString();
|
||||
String plain = String.format("%-5s", name);
|
||||
|
||||
if (!styled) {
|
||||
return plain;
|
||||
}
|
||||
|
||||
if (Level.ERROR.equals(level)) {
|
||||
return ANSI_BOLD + ANSI_RED + "[x] " + plain + ANSI_RESET;
|
||||
}
|
||||
if (Level.WARN.equals(level)) {
|
||||
return ANSI_YELLOW + "[!] " + plain + ANSI_RESET;
|
||||
}
|
||||
if (Level.DEBUG.equals(level) || Level.TRACE.equals(level)) {
|
||||
return ANSI_DIM + "[.] " + plain + ANSI_RESET;
|
||||
}
|
||||
|
||||
return ANSI_GREEN + "[i] " + plain + ANSI_RESET;
|
||||
}
|
||||
|
||||
public static String logger(String loggerName, boolean styled) {
|
||||
String compact = compactLoggerName(loggerName);
|
||||
String plain = fit(compact, LOGGER_WIDTH);
|
||||
return styled ? ANSI_CYAN + plain + ANSI_RESET : plain;
|
||||
}
|
||||
|
||||
private static String compactLoggerName(String loggerName) {
|
||||
if (loggerName == null || loggerName.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
int lastDot = loggerName.lastIndexOf('.');
|
||||
return lastDot >= 0 ? loggerName.substring(lastDot + 1) : loggerName;
|
||||
}
|
||||
|
||||
private static String fit(String value, int width) {
|
||||
String safe = value == null ? "" : value;
|
||||
if (safe.length() > width) {
|
||||
return safe.substring(0, Math.max(0, width - 3)) + "...";
|
||||
}
|
||||
|
||||
return String.format("%-" + width + "s", safe);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<conversionRule conversionWord="morningstarLevel" class="com.eu.habbo.util.logback.ConsoleLevelConverter" />
|
||||
<conversionRule conversionWord="morningstarLogger" class="com.eu.habbo.util.logback.ConsoleLoggerConverter" />
|
||||
|
||||
<appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%-14thread] %-5level %-36logger{36} - %msg%n</pattern>
|
||||
<pattern>%d{HH:mm:ss.SSS} %morningstarLevel [%-12thread] %morningstarLogger | %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
@@ -65,4 +68,4 @@
|
||||
<appender-ref ref="FileErrors" />
|
||||
<appender-ref ref="FileErrorsSql" />
|
||||
</root>
|
||||
</configuration>
|
||||
</configuration>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.eu.habbo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ConsoleLogbackLayoutTest {
|
||||
@Test
|
||||
void consolePatternKeepsStartupMessagesReadable() throws Exception {
|
||||
String logback = Files.readString(Path.of("src/main/resources/logback.xml"));
|
||||
|
||||
assertTrue(logback.contains("morningstarLevel"), "console should use the adaptive level formatter");
|
||||
assertTrue(logback.contains("morningstarLogger"), "console should use the adaptive logger formatter");
|
||||
assertTrue(logback.contains("| %msg%n"), "console should leave a clear message column");
|
||||
assertFalse(logback.contains("%-36logger{36}"), "wide package loggers waste console space");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.eu.habbo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class EmulatorStartupConsoleTest {
|
||||
@Test
|
||||
void startupHeroUsesUniversalAsciiLayout() {
|
||||
String hero = Emulator.startupHero();
|
||||
|
||||
assertTrue(hero.contains("__ __ ___ ____"));
|
||||
assertTrue(hero.contains("MORNINGSTAR EXTENDED"));
|
||||
assertTrue(hero.contains("Version"));
|
||||
assertTrue(hero.contains("Build"));
|
||||
assertFalse(hero.contains("\u001B["), "startup hero must not require ANSI support");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupHeroCanRenderStyledLayoutWhenAnsiIsAvailable() {
|
||||
String hero = Emulator.startupHero(true);
|
||||
|
||||
assertTrue(hero.contains("\u001B["), "styled hero should include ANSI colors");
|
||||
assertTrue(hero.contains("[OK] MORNINGSTAR EXTENDED"));
|
||||
assertTrue(hero.contains("[JVM]"));
|
||||
assertTrue(hero.endsWith("\u001B[0m\n"), "styled hero should reset terminal attributes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void consoleStyleAutoDetectsWindowsTerminal() {
|
||||
assertTrue(Emulator.shouldStyleConsole(
|
||||
Map.of("WT_SESSION", "abc123"),
|
||||
true,
|
||||
"Windows 11",
|
||||
"auto"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consoleStyleFallsBackWhenOutputIsNotInteractive() {
|
||||
assertFalse(Emulator.shouldStyleConsole(
|
||||
Map.of("WT_SESSION", "abc123"),
|
||||
false,
|
||||
"Windows 11",
|
||||
"auto"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consoleStyleCanBeForcedOff() {
|
||||
assertFalse(Emulator.shouldStyleConsole(
|
||||
Map.of("WT_SESSION", "abc123"),
|
||||
true,
|
||||
"Windows 11",
|
||||
"plain"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void windowsAnsiModeInstallsJansiBeforePrintingStartupHero() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java"));
|
||||
|
||||
assertTrue(source.contains("AnsiConsole.systemInstall()"),
|
||||
"forced ANSI mode must install the Jansi bridge for Windows CMD/System.out");
|
||||
assertTrue(source.contains("configureAnsiConsole(styledConsole)"),
|
||||
"console bridge must be configured before startupHero is printed");
|
||||
assertTrue(source.indexOf("configureAnsiConsole(styledConsole)") < source.indexOf("startupHero(styledConsole)"),
|
||||
"Jansi must be installed before writing ANSI startup output");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersGuiEnabledBeforeReadingIt() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java"));
|
||||
|
||||
assertTrue(source.contains("register(\"gui.enabled\", \"0\")"),
|
||||
"gui.enabled must be registered disabled by default so it does not log missing config errors or start the UI unexpectedly");
|
||||
assertTrue(source.contains("register(\"gui.autostart.enabled\", \"0\")"),
|
||||
"GUI autostart must use a new disabled-by-default key so old gui.enabled=1 settings do not launch the current UI");
|
||||
assertTrue(source.indexOf("register(\"gui.autostart.enabled\", \"0\")") < source.indexOf("shouldLaunchGui()"),
|
||||
"GUI autostart must be registered before the launch decision");
|
||||
assertFalse(source.contains("getBoolean(\"gui.enabled\", true)"),
|
||||
"GUI must not use a true fallback");
|
||||
assertFalse(source.contains("getBoolean(\"gui.enabled\", false)"),
|
||||
"legacy gui.enabled must not control startup anymore");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.eu.habbo.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CommandDescriptionTextsContractTest {
|
||||
private static final Path FULL_DATABASE = Path.of("../Default Database/FullDatabase.sql");
|
||||
private static final Path LIVE_SCHEMA_UPDATE = Path.of("../Database Updates/003_live_required_schema.sql");
|
||||
|
||||
private static final List<String> REQUIRED_DESCRIPTION_KEYS = List.of(
|
||||
"commands.description.acc_modtool_room_info",
|
||||
"commands.description.cmd_add_youtube_playlist",
|
||||
"commands.description.cmd_disablemassmentions",
|
||||
"commands.description.cmd_disablementions",
|
||||
"commands.description.cmd_give_prefix",
|
||||
"commands.description.cmd_hidewired",
|
||||
"commands.description.cmd_list_prefixes",
|
||||
"commands.description.cmd_remove_prefix",
|
||||
"commands.description.cmd_setroom_template",
|
||||
"commands.description.cmd_update_youtube_playlists"
|
||||
);
|
||||
|
||||
@Test
|
||||
void fullDatabaseDefinesCommandDescriptionsUsedByCommandsList() throws IOException {
|
||||
assertContainsAllDescriptionKeys(Files.readString(FULL_DATABASE), "FullDatabase.sql");
|
||||
}
|
||||
|
||||
@Test
|
||||
void liveSchemaUpdateBackfillsCommandDescriptionsForExistingDatabases() throws IOException {
|
||||
assertContainsAllDescriptionKeys(Files.readString(LIVE_SCHEMA_UPDATE), "003_live_required_schema.sql");
|
||||
}
|
||||
|
||||
private static void assertContainsAllDescriptionKeys(String source, String fileName) {
|
||||
for (String key : REQUIRED_DESCRIPTION_KEYS) {
|
||||
assertTrue(source.contains("'" + key + "'"),
|
||||
fileName + " must define " + key + " to avoid TextsManager missing-key logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.eu.habbo.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CommandTextLookupContractTest {
|
||||
private static final Path TEXTS_MANAGER = Path.of("src/main/java/com/eu/habbo/core/TextsManager.java");
|
||||
private static final Path COMMANDS_COMMAND = Path.of("src/main/java/com/eu/habbo/habbohotel/commands/CommandsCommand.java");
|
||||
private static final Path AVAILABLE_COMMANDS_COMPOSER = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/outgoing/commands/AvailableCommandsComposer.java");
|
||||
|
||||
@Test
|
||||
void textsManagerExposesQuietFallbackLookupForOptionalTexts() throws IOException {
|
||||
String source = Files.readString(TEXTS_MANAGER);
|
||||
|
||||
assertTrue(source.contains("public String getValueQuietly(String key, String defaultValue)"));
|
||||
assertTrue(source.contains("return this.texts.getProperty(key, defaultValue);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandListsUseQuietDescriptionLookups() throws IOException {
|
||||
String commandsCommand = Files.readString(COMMANDS_COMMAND);
|
||||
String availableCommandsComposer = Files.readString(AVAILABLE_COMMANDS_COMPOSER);
|
||||
|
||||
assertTrue(commandsCommand.contains("getValueQuietly(textKey, \"\")"),
|
||||
":commands should not log an error when an optional command description is missing");
|
||||
assertTrue(availableCommandsComposer.contains("getValueQuietly(\"commands.description.\" + cmd.permission, cmd.permission)"),
|
||||
"available commands composer should not log an error when an optional command description is missing");
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.eu.habbo.habbohotel.bots;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BotPickupOwnershipContractTest {
|
||||
private static String source() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomOwnerPickupReturnsBotToOriginalOwner() throws Exception {
|
||||
String source = source();
|
||||
|
||||
assertTrue(source.contains("HabboInfo receiverInfo = resolvePickupReceiver(bot, habbo);"),
|
||||
"bot pickup should resolve the receiver without blindly using the picker");
|
||||
assertTrue(source.contains("private HabboInfo resolvePickupReceiver(Bot bot, Habbo picker)"),
|
||||
"bot pickup receiver logic should be centralized");
|
||||
assertTrue(source.contains("return Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId());"),
|
||||
"when a room owner picks up someone else's bot, it should return to the original bot owner");
|
||||
assertTrue(source.contains("Room botRoom = bot.getRoom();"),
|
||||
"pickup should remove the bot from the bot's current room, not the receiver's current room");
|
||||
assertTrue(source.contains("botRoom.removeBot(bot);"),
|
||||
"bot removal should work even when the original owner is offline");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.eu.habbo.habbohotel.catalog;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class VoucherClaimContractTest {
|
||||
private static String voucherSource() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java"));
|
||||
}
|
||||
|
||||
private static String catalogManagerSource() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voucherClaimIsSynchronizedAndPersistsBeforeRewardEligibility() throws Exception {
|
||||
String source = voucherSource();
|
||||
|
||||
assertTrue(source.contains("public synchronized ClaimResult claimForUser(int userId)"),
|
||||
"voucher claim should check limits and persist history under a per-voucher lock");
|
||||
assertTrue(source.contains("private boolean insertHistoryEntry"),
|
||||
"history insert should report database failure to the caller");
|
||||
|
||||
int insertCall = source.indexOf("insertHistoryEntry(userId, timestamp)");
|
||||
int memoryAppend = source.indexOf("this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp))");
|
||||
|
||||
assertTrue(insertCall > -1, "claimForUser must persist the history row");
|
||||
assertTrue(memoryAppend > insertCall,
|
||||
"in-memory history must only be updated after the database insert succeeds");
|
||||
}
|
||||
|
||||
@Test
|
||||
void catalogRewardsOnlyAfterVoucherClaimSucceeds() throws Exception {
|
||||
String source = catalogManagerSource();
|
||||
|
||||
int claim = source.indexOf("Voucher.ClaimResult claimResult = voucher.claimForUser");
|
||||
int claimedGuard = source.indexOf("case CLAIMED", claim);
|
||||
int pointsGrant = source.indexOf("client.getHabbo().givePoints", claim);
|
||||
int creditsGrant = source.indexOf("client.getHabbo().giveCredits", claim);
|
||||
|
||||
assertTrue(claim > -1, "CatalogManager must claim the voucher before applying rewards");
|
||||
assertTrue(claimedGuard > claim, "voucher rewards should only continue for a CLAIMED result");
|
||||
assertTrue(pointsGrant > claimedGuard, "points must be granted only after CLAIMED");
|
||||
assertTrue(creditsGrant > claimedGuard, "credits must be granted only after CLAIMED");
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.eu.habbo.habbohotel.catalog.marketplace;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MarketPlaceCreditClaimContractTest {
|
||||
private static String marketPlaceSource() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void soldOfferIsDetachedBeforeCreditsAreGranted() throws Exception {
|
||||
String source = marketPlaceSource();
|
||||
int getCreditsStart = source.indexOf("public static void getCredits");
|
||||
int removeUserCall = source.indexOf("removeUser(offer)", getCreditsStart);
|
||||
int creditAccumulator = source.indexOf("credits += offer.getPrice()", getCreditsStart);
|
||||
int inventoryRemoval = source.indexOf("removeMarketplaceOffer(offer)", getCreditsStart);
|
||||
|
||||
assertTrue(getCreditsStart > -1, "MarketPlace.getCredits must exist");
|
||||
assertTrue(removeUserCall > -1, "Sold marketplace offers must be detached in the database");
|
||||
assertTrue(removeUserCall < creditAccumulator,
|
||||
"Credits must not be granted until the sold offer is detached from the seller in the database");
|
||||
assertTrue(removeUserCall < inventoryRemoval,
|
||||
"The in-memory sold offer should remain claimable if the database detach fails");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detachFailureIsObservableByCaller() throws Exception {
|
||||
String source = marketPlaceSource();
|
||||
|
||||
assertTrue(source.contains("private static boolean removeUser"),
|
||||
"removeUser must report whether the marketplace ownership update succeeded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.eu.habbo.habbohotel.guilds;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import com.eu.habbo.messages.ClientMessage;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GuildBadgeBuilderTest {
|
||||
@Test
|
||||
void buildsBadgeFromFlatPartTriplets() {
|
||||
ClientMessage packet = messageWithInts(
|
||||
1, 2, 4,
|
||||
35, 8, 0
|
||||
);
|
||||
|
||||
assertEquals("b001024s035080", GuildBadgeBuilder.readBadge(packet, 6));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCountThatDoesNotRepresentCompleteTriplets() {
|
||||
ClientMessage packet = messageWithInts(1, 2, 4);
|
||||
|
||||
assertNull(GuildBadgeBuilder.readBadge(packet, 4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPayloadShorterThanDeclaredCount() {
|
||||
ClientMessage packet = messageWithInts(1, 2);
|
||||
|
||||
assertNull(GuildBadgeBuilder.readBadge(packet, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTooManyBadgeParts() {
|
||||
ClientMessage packet = messageWithInts(
|
||||
1, 1, 4,
|
||||
2, 1, 4,
|
||||
3, 1, 4,
|
||||
4, 1, 4,
|
||||
5, 1, 4,
|
||||
6, 1, 4
|
||||
);
|
||||
|
||||
assertNull(GuildBadgeBuilder.readBadge(packet, 18));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPartValuesOutsideBadgeCodeRanges() {
|
||||
assertNull(GuildBadgeBuilder.readBadge(messageWithInts(1000, 1, 4), 3));
|
||||
assertNull(GuildBadgeBuilder.readBadge(messageWithInts(1, 100, 4), 3));
|
||||
assertNull(GuildBadgeBuilder.readBadge(messageWithInts(1, 1, 9), 3));
|
||||
}
|
||||
|
||||
private static ClientMessage messageWithInts(int... values) {
|
||||
var buffer = Unpooled.buffer(values.length * Integer.BYTES);
|
||||
for (int value : values) {
|
||||
buffer.writeInt(value);
|
||||
}
|
||||
return new ClientMessage(0, buffer);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eu.habbo.habbohotel.guilds;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GuildManagerMembershipContractTest {
|
||||
private static String guildManagerSource() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptRequestOnlyPromotesPendingMembershipRows() throws Exception {
|
||||
String source = guildManagerSource();
|
||||
|
||||
assertTrue(source.contains("UPDATE guilds_members SET level_id = ?, member_since = ? WHERE user_id = ? AND guild_id = ? AND level_id = ?"),
|
||||
"accepting a guild request must only promote rows still in REQUESTED state");
|
||||
assertTrue(source.contains("statement.setInt(5, GuildRank.REQUESTED.type);"),
|
||||
"the accept-request update must bind the expected REQUESTED rank guard");
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.eu.habbo.habbohotel.items.interactions;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RentableSpaceChargeContractTest {
|
||||
@Test
|
||||
void rentingSpaceChargesCreditsBeforeMarkingSpaceRented() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java"));
|
||||
int rentMethod = source.indexOf("public void rent(Habbo habbo)");
|
||||
|
||||
assertTrue(rentMethod >= 0, "InteractionRentableSpace must keep explicit rent handling");
|
||||
|
||||
String rentHandling = source.substring(rentMethod, Math.min(source.length(), rentMethod + 1400));
|
||||
|
||||
assertTrue(rentHandling.contains("int cost = this.rentCost();"),
|
||||
"Rent cost must be computed once before charging");
|
||||
assertTrue(rentHandling.contains("boolean hasInfiniteCredits = habbo.hasPermission(Permission.ACC_INFINITE_CREDITS);"),
|
||||
"Renting must honor infinite-credit staff permission before charging");
|
||||
assertTrue(rentHandling.contains("!hasInfiniteCredits && habbo.getHabboInfo().getCredits() < cost"),
|
||||
"Renting must reject non-staff users without enough credits for the computed cost");
|
||||
assertTrue(rentHandling.contains("habbo.giveCredits(-cost);"),
|
||||
"Renting must deduct the computed credit cost");
|
||||
assertTrue(rentHandling.indexOf("habbo.giveCredits(-cost);") < rentHandling.indexOf("this.setRenterId"),
|
||||
"Credits must be charged before the rentable space is marked as rented");
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.eu.habbo.habbohotel.rooms;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RoomTradeManagerContractTest {
|
||||
private static String roomTradeManagerSource() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeManager.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startTradeRejectsParticipantsAlreadyInActiveTradeInsideLock() throws Exception {
|
||||
String source = roomTradeManagerSource();
|
||||
int synchronizedBlock = source.indexOf("synchronized (this.activeTrades)");
|
||||
int activeGuard = source.indexOf("hasActiveTrade(userOne) || this.hasActiveTrade(userTwo)");
|
||||
int addTrade = source.indexOf("this.activeTrades.add(trade)");
|
||||
|
||||
assertTrue(synchronizedBlock > -1, "RoomTradeManager.startTrade must lock activeTrades before mutation");
|
||||
assertTrue(activeGuard > synchronizedBlock,
|
||||
"startTrade must check both participants for an existing active trade while holding the activeTrades lock");
|
||||
assertTrue(activeGuard < addTrade,
|
||||
"duplicate participant guard must run before a new RoomTrade is added");
|
||||
assertTrue(source.contains("private boolean hasActiveTrade(Habbo user)"),
|
||||
"active trade lookup should be reusable under the same activeTrades lock");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eu.habbo.habbohotel.rooms;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RoomTradeSafetyContractTest {
|
||||
private static String roomTradeSource() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlFailureStopsBeforeInventoryTransfer() throws Exception {
|
||||
String source = roomTradeSource();
|
||||
int catchIndex = source.indexOf("catch (SQLException e)");
|
||||
int inventoryTransferIndex = source.indexOf("THashSet<HabboItem> itemsUserOne");
|
||||
|
||||
assertTrue(catchIndex > -1, "RoomTrade must handle SQL failures explicitly");
|
||||
assertTrue(inventoryTransferIndex > catchIndex, "Inventory transfer should happen after SQL ownership updates");
|
||||
assertTrue(source.substring(catchIndex, inventoryTransferIndex).contains("return false"),
|
||||
"SQL failures must abort the trade before in-memory inventory/credit transfer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemOwnersChangeOnlyAfterDatabaseBatchSucceeds() throws Exception {
|
||||
String source = roomTradeSource();
|
||||
int firstOwnerMutation = source.indexOf("item.setUserId(");
|
||||
int batchExecution = source.indexOf("statement.executeBatch();");
|
||||
|
||||
assertTrue(firstOwnerMutation > -1, "RoomTrade should update in-memory item owners after commit");
|
||||
assertTrue(batchExecution > -1, "RoomTrade should persist item owner changes with a batch update");
|
||||
assertTrue(firstOwnerMutation > batchExecution,
|
||||
"In-memory item owners must not change until the database batch has succeeded");
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.eu.habbo.habbohotel.users.infostand;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class InfostandBackgroundManagerTest {
|
||||
@Test
|
||||
void summaryKeepsStartupLogCompact() {
|
||||
assertEquals(
|
||||
"Infostand Background Manager -> Loaded! (260 assets)",
|
||||
InfostandBackgroundManager.summary(188, 22, 9, 16, 25));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.eu.habbo.messages;
|
||||
|
||||
import com.eu.habbo.messages.incoming.Incoming;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PacketNamesContractTest {
|
||||
@Test
|
||||
void incomingPacketNameIdsAreUnique() throws Exception {
|
||||
assertPublicFinalPacketIdsAreUnique(Incoming.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outgoingPacketNameIdsAreUnique() throws Exception {
|
||||
assertPublicFinalPacketIdsAreUnique(Outgoing.class);
|
||||
}
|
||||
|
||||
private static void assertPublicFinalPacketIdsAreUnique(Class<?> packetClass) throws Exception {
|
||||
Map<Integer, String> seen = new HashMap<>();
|
||||
Map<Integer, String> duplicates = new HashMap<>();
|
||||
|
||||
for (Field field : packetClass.getFields()) {
|
||||
int modifiers = field.getModifiers();
|
||||
if (!Modifier.isPublic(modifiers)
|
||||
|| !Modifier.isStatic(modifiers)
|
||||
|| !Modifier.isFinal(modifiers)
|
||||
|| field.getType() != int.class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int packetId = field.getInt(null);
|
||||
if (packetId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String previous = seen.putIfAbsent(packetId, field.getName());
|
||||
if (previous != null) {
|
||||
duplicates.put(packetId, previous + " / " + field.getName());
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(duplicates.isEmpty(), packetClass.getSimpleName() + " has duplicate packet IDs: " + duplicates);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CatalogAdminOfferMutationContractTest {
|
||||
private static final Path CREATE_SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java");
|
||||
private static final Path SAVE_SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java");
|
||||
|
||||
@Test
|
||||
void createAndSaveValidatePayloadAndTargetPageBeforeWriting() throws IOException {
|
||||
String create = Files.readString(CREATE_SOURCE);
|
||||
String save = Files.readString(SAVE_SOURCE);
|
||||
|
||||
assertTrue(create.contains("CatalogAdminOfferPayload.validate("));
|
||||
assertTrue(save.contains("CatalogAdminOfferPayload.validate("));
|
||||
assertTrue(create.contains("getCatalogPage(payload.pageId, payload.pageType) == null"));
|
||||
assertTrue(save.contains("getCatalogPage(payload.pageId, payload.pageType) == null"));
|
||||
|
||||
int createValidation = create.indexOf("CatalogAdminOfferPayload.validate(");
|
||||
int createInsert = create.indexOf("INSERT INTO catalog_items");
|
||||
int saveValidation = save.indexOf("CatalogAdminOfferPayload.validate(");
|
||||
int saveUpdate = save.indexOf("UPDATE catalog_items");
|
||||
|
||||
assertTrue(createValidation < createInsert, "create offer should validate before insert SQL is prepared");
|
||||
assertTrue(saveValidation < saveUpdate, "save offer should validate before update SQL is prepared");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveOfferReportsMissingRowsInsteadOfAlwaysSucceeding() throws IOException {
|
||||
String save = Files.readString(SAVE_SOURCE);
|
||||
|
||||
assertTrue(save.contains("statement.executeUpdate() == 0"));
|
||||
assertTrue(save.contains("Offer not found: "));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CatalogAdminOfferPayloadTest {
|
||||
@Test
|
||||
void acceptsAndNormalizesValidOfferPayload() {
|
||||
CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(
|
||||
42, "1, 2,3", "Rare Chair", 100, 5, 0, 1, 0,
|
||||
"extra", true, 0, 0, 10, CatalogPageType.NORMAL);
|
||||
|
||||
assertNotNull(payload);
|
||||
assertEquals("1,2,3", payload.itemIds);
|
||||
assertEquals("Rare Chair", payload.catalogName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidItemIdsAndNegativeEconomyValues() {
|
||||
assertNull(CatalogAdminOfferPayload.validate(42, "1,abc", "Name", 0, 0, 0, 1, 0,
|
||||
"", false, 0, 0, 0, CatalogPageType.NORMAL));
|
||||
assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", -1, 0, 0, 1, 0,
|
||||
"", false, 0, 0, 0, CatalogPageType.NORMAL));
|
||||
assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", 0, 0, 0, 0, 0,
|
||||
"", false, 0, 0, 0, CatalogPageType.NORMAL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderOffersStillRequireSafeCommonFields() {
|
||||
assertNotNull(CatalogAdminOfferPayload.validate(42, "", "BC Offer", -1, -1, -1, -1, -1,
|
||||
"", false, -1, -1, 0, CatalogPageType.BUILDER));
|
||||
assertNull(CatalogAdminOfferPayload.validate(0, "1", "BC Offer", 0, 0, 0, 1, 0,
|
||||
"", false, 0, 0, 0, CatalogPageType.BUILDER));
|
||||
assertNull(CatalogAdminOfferPayload.validate(42, "1", "", 0, 0, 0, 1, 0,
|
||||
"", false, 0, 0, 0, CatalogPageType.BUILDER));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CatalogAdminPageMutationContractTest {
|
||||
private static final Path CREATE_SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java");
|
||||
private static final Path SAVE_SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java");
|
||||
private static final Path MOVE_SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java");
|
||||
private static final Path DELETE_SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java");
|
||||
|
||||
@Test
|
||||
void pageParentChecksStayWithinTheSameCatalogPageType() throws IOException {
|
||||
String create = Files.readString(CREATE_SOURCE);
|
||||
String save = Files.readString(SAVE_SOURCE);
|
||||
String move = Files.readString(MOVE_SOURCE);
|
||||
|
||||
assertTrue(create.contains("getCatalogPage(parentId, pageType)"));
|
||||
assertTrue(save.contains("getCatalogPage(parentId, pageType)"));
|
||||
assertTrue(save.contains("getCatalogPage(current, pageType)"));
|
||||
assertTrue(move.contains("getCatalogPage(newParentId, pageType)"));
|
||||
assertTrue(move.contains("getCatalogPage(current, pageType)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void movePageValidatesTargetBeforeTogglingVisibilityOrEnabledState() throws IOException {
|
||||
String move = Files.readString(MOVE_SOURCE);
|
||||
|
||||
int pageLookup = move.indexOf("getCatalogPage(pageId, pageType)");
|
||||
int enabledToggle = move.indexOf("SET enabled = IF");
|
||||
int visibleToggle = move.indexOf("SET visible = IF");
|
||||
|
||||
assertTrue(pageLookup >= 0, "move page should load the page before mutating it");
|
||||
assertTrue(pageLookup < enabledToggle, "enabled toggle must not run before page existence is checked");
|
||||
assertTrue(pageLookup < visibleToggle, "visible toggle must not run before page existence is checked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageMutationsReportMissingRowsInsteadOfAlwaysSucceeding() throws IOException {
|
||||
String save = Files.readString(SAVE_SOURCE);
|
||||
String move = Files.readString(MOVE_SOURCE);
|
||||
String delete = Files.readString(DELETE_SOURCE);
|
||||
|
||||
assertTrue(save.contains("statement.executeUpdate() == 0"));
|
||||
assertTrue(move.contains("statement.executeUpdate() == 0"));
|
||||
assertTrue(delete.contains("statement.executeUpdate() == 0"));
|
||||
}
|
||||
}
|
||||
+29
@@ -78,4 +78,33 @@ class FurniDataManagerTest {
|
||||
assertEquals(assetBase.resolve("gamedata").resolve("FurnitureData.json"), source.path());
|
||||
assertFalse(source.directory());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersRendererConfigOverLegacyFurnidataPath(@TempDir Path dir) throws Exception {
|
||||
Path legacy = dir.resolve("legacy").resolve("FurnitureData.json");
|
||||
Files.createDirectories(legacy.getParent());
|
||||
Files.writeString(legacy, "{}");
|
||||
|
||||
Path assetBase = dir.resolve("nitro-assets");
|
||||
Path rendererSource = assetBase.resolve("gamedata").resolve("FurnitureData.json");
|
||||
Files.createDirectories(rendererSource.getParent());
|
||||
Files.writeString(rendererSource, "{}");
|
||||
|
||||
Path rendererConfig = dir.resolve("renderer-config.json");
|
||||
Files.writeString(rendererConfig, """
|
||||
{
|
||||
"gamedata.url": "http://localhost:5173/nitro-assets/gamedata",
|
||||
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%"
|
||||
}
|
||||
""");
|
||||
|
||||
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolveConfigured(
|
||||
legacy.toString(),
|
||||
rendererConfig.toString(),
|
||||
assetBase.toString());
|
||||
|
||||
assertTrue(source.ok());
|
||||
assertEquals(rendererSource, source.path());
|
||||
assertEquals("renderer-config furnidata.url", source.message());
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.eu.habbo.messages.incoming.furnieditor;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class FurniEditorUpdatePayloadTest {
|
||||
@Test
|
||||
void acceptsSafeEditorFields() {
|
||||
FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(JsonParser.parseString("""
|
||||
{
|
||||
"publicName": "Rare Chair",
|
||||
"type": "s",
|
||||
"width": 2,
|
||||
"length": 1,
|
||||
"stackHeight": 1.5,
|
||||
"allowTrade": true,
|
||||
"interactionModesCount": 3
|
||||
}
|
||||
""").getAsJsonObject());
|
||||
|
||||
assertTrue(payload.valid());
|
||||
assertEquals(7, payload.values.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOutOfRangeAndOversizedFields() {
|
||||
assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"width\":-1}").getAsJsonObject()).valid());
|
||||
assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"stackHeight\":1000}").getAsJsonObject()).valid());
|
||||
assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"allowTrade\":2}").getAsJsonObject()).valid());
|
||||
assertFalse(FurniEditorUpdatePayload.validate(JsonParser.parseString("{\"publicName\":\"" + "x".repeat(57) + "\"}").getAsJsonObject()).valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresUnknownFieldsButRequiresAtLeastOneValidField() {
|
||||
FurniEditorUpdatePayload payload = FurniEditorUpdatePayload.validate(
|
||||
JsonParser.parseString("{\"itemName\":\"blocked\",\"unknown\":true}").getAsJsonObject());
|
||||
|
||||
assertFalse(payload.valid());
|
||||
assertEquals("No valid fields to update", payload.error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsCatalogItemIdsTokenPattern() {
|
||||
assertEquals("%,12,%", FurniEditorHelper.catalogItemIdsTokenPattern(12));
|
||||
assertTrue((",112,12,13,").contains(",12,"));
|
||||
assertFalse((",112,13,").contains(",12,"));
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.eu.habbo.messages.incoming.polls;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PollRoomScopeContractTest {
|
||||
@Test
|
||||
void pollHandlersRequireMatchingCurrentRoomPoll() throws Exception {
|
||||
assertRequiresMatchingRoomPoll("AnswerPollEvent.java");
|
||||
assertRequiresMatchingRoomPoll("CancelPollEvent.java");
|
||||
assertRequiresMatchingRoomPoll("GetPollDataEvent.java");
|
||||
}
|
||||
|
||||
private void assertRequiresMatchingRoomPoll(String fileName) throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/polls/" + fileName));
|
||||
int packetPollId = source.indexOf("int pollId = this.packet.readInt();");
|
||||
int pollLookup = source.indexOf("getPoll(pollId)");
|
||||
|
||||
assertTrue(packetPollId >= 0, fileName + " must read the poll id from the packet");
|
||||
assertTrue(pollLookup >= 0, fileName + " must look up the requested poll explicitly");
|
||||
|
||||
String guardedSection = source.substring(packetPollId, pollLookup);
|
||||
|
||||
assertTrue(guardedSection.contains("getCurrentRoom()"),
|
||||
fileName + " must bind poll actions to the caller's current room");
|
||||
assertTrue(guardedSection.contains("room == null || room.getPollId() != pollId"),
|
||||
fileName + " must reject poll ids that are not active in the current room");
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.eu.habbo.messages.incoming.rooms.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MonsterPlantSeedOwnershipContractTest {
|
||||
@Test
|
||||
void monsterPlantSeedsCanOnlyBeRedeemedByTheirOwner() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java"));
|
||||
int seedBranch = source.indexOf("item instanceof InteractionMonsterPlantSeed");
|
||||
|
||||
assertTrue(seedBranch >= 0, "ToggleFloorItemEvent must keep monsterplant seed handling explicit");
|
||||
|
||||
String seedHandling = source.substring(seedBranch, Math.min(source.length(), seedBranch + 1400));
|
||||
|
||||
String ownershipGuard = "if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId())";
|
||||
|
||||
assertTrue(seedHandling.contains(ownershipGuard),
|
||||
"Monsterplant seed redemption must reject callers who do not own the seed");
|
||||
assertTrue(seedHandling.contains("createMonsterplant"),
|
||||
"Monsterplant seed handling must create the pet inside the guarded branch");
|
||||
assertTrue(seedHandling.indexOf(ownershipGuard) < seedHandling.indexOf("createMonsterplant"),
|
||||
"Ownership rejection must happen before creating the pet");
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.eu.habbo.messages.incoming.rooms.items;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedeemClothingContractTest {
|
||||
private static String source() throws Exception {
|
||||
return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clothingIsGrantedBeforeVoucherFurnitureIsConsumed() throws Exception {
|
||||
String source = source();
|
||||
|
||||
int grantCall = source.indexOf("grantClothing(");
|
||||
int roomRemoval = source.indexOf("removeHabboItem(item)");
|
||||
int deleteItem = source.indexOf("new QueryDeleteHabboItem(item.getId())");
|
||||
|
||||
assertTrue(source.contains("private boolean grantClothing(int clothingId)"),
|
||||
"clothing DB insert should report whether the grant succeeded");
|
||||
assertTrue(grantCall > -1, "redeem path should call grantClothing before consuming the item");
|
||||
assertTrue(grantCall < roomRemoval, "room item must not be removed before the clothing grant succeeds");
|
||||
assertTrue(grantCall < deleteItem, "voucher furniture must not be deleted before the clothing grant succeeds");
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eu.habbo.messages.incoming.rooms.users;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RoomModerationScopeContractTest {
|
||||
@Test
|
||||
void roomUserBanAndMuteAreScopedToCurrentRoom() throws Exception {
|
||||
Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/users");
|
||||
|
||||
for (String handler : new String[]{"RoomUserBanEvent.java", "RoomUserMuteEvent.java", "UnbanRoomUserEvent.java"}) {
|
||||
String source = Files.readString(base.resolve(handler));
|
||||
|
||||
assertTrue(source.contains("getCurrentRoom()"),
|
||||
handler + " must authorize room moderation against the user's current room");
|
||||
assertTrue(source.contains("room.getId() != roomId"),
|
||||
handler + " must reject client-supplied room ids that do not match the current room");
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.eu.habbo.messages.incoming.rooms.users;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RoomUserRemoveRightsContractTest {
|
||||
private static final Path SOURCE = Path.of(
|
||||
"src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserRemoveRightsEvent.java");
|
||||
|
||||
@Test
|
||||
void removeRightsBatchIsBoundedAndRequiresCompletePayload() throws IOException {
|
||||
String source = Files.readString(SOURCE);
|
||||
|
||||
assertTrue(source.contains("private static final int MAX_RIGHTS_REMOVALS = 100;"));
|
||||
assertTrue(source.contains("PacketGuard.isCountInRange(amount, 1, MAX_RIGHTS_REMOVALS)"));
|
||||
assertTrue(source.contains("PacketGuard.hasFixedWidthEntries(this.packet, amount, BYTES_PER_USER_ID)"));
|
||||
|
||||
int guardIndex = source.indexOf("PacketGuard.isCountInRange(amount, 1, MAX_RIGHTS_REMOVALS)");
|
||||
int payloadIndex = source.indexOf("PacketGuard.hasFixedWidthEntries(this.packet, amount, BYTES_PER_USER_ID)");
|
||||
int readIndex = source.indexOf("int userId = this.packet.readInt();");
|
||||
int removeIndex = source.indexOf("room.removeRights(userId);");
|
||||
|
||||
assertTrue(guardIndex < readIndex, "batch size should be validated before reading user ids");
|
||||
assertTrue(payloadIndex < readIndex, "payload length should be validated before reading user ids");
|
||||
assertTrue(readIndex < removeIndex, "rights should only be removed after reading a validated user id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.eu.habbo.util.logback;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ConsoleStyleTest {
|
||||
@Test
|
||||
void formatsLevelWithIconAndColorWhenStyled() {
|
||||
String formatted = ConsoleStyle.level(Level.WARN, true);
|
||||
|
||||
assertTrue(formatted.contains("\u001B["));
|
||||
assertTrue(formatted.contains("[!] WARN "));
|
||||
assertTrue(formatted.endsWith("\u001B[0m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatsLevelAsPlainTextWhenNotStyled() {
|
||||
assertEquals("WARN ", ConsoleStyle.level(Level.WARN, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatsLoggerWithColorWhenStyled() {
|
||||
String formatted = ConsoleStyle.logger("com.eu.habbo.networking.Server", true);
|
||||
|
||||
assertTrue(formatted.contains("\u001B["));
|
||||
assertTrue(formatted.contains("Server"));
|
||||
assertTrue(formatted.endsWith("\u001B[0m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsLoggerPlainAndCompactWhenNotStyled() {
|
||||
assertEquals("Server ", ConsoleStyle.logger("com.eu.habbo.networking.Server", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void honorsPlainOverrideEvenInWindowsTerminal() {
|
||||
assertFalse(ConsoleStyle.isEnabled(
|
||||
Map.of("WT_SESSION", "abc123"),
|
||||
true,
|
||||
"Windows 11",
|
||||
"plain"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# Furnidata create-if-missing (upsert) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement task-by-task. Steps use checkbox (`- [ ]`).
|
||||
|
||||
**Goal:** Let the Furni Editor create a complete furnidata entry for a furni that has none, by making the existing `FurniEditorUpdateFurnidata` (10046) handler an upsert.
|
||||
|
||||
**Architecture:** Reuse packet 10046 (no renderer changes, no new packet). Emulator: new `FurnidataWriter.create(...)` (JSON5-preserving append) + handler routes "classname missing → create complete entry from `items_base`" + config key. Client: unlock name/desc when the entry is missing and relabel Save to "Create entry".
|
||||
|
||||
**Tech Stack:** Java 21 (Arcturus emulator), Gson/JSON5, React/TS (Nitro-V3 client).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-13-furnidata-create-if-missing-design.md`
|
||||
|
||||
**Environment note:** On this machine furnidata is a SINGLE file `Nitro-Files/nitro-assets/gamedata/FurnitureData.json` (`FurnitureTextProvider.isSourceDirectory()==false`). Plan must also handle split-tier (directory) since the code supports it.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- Modify: `Emulator/.../habbohotel/items/FurnidataWriter.java` — add `create(...)` + a `CreateResult` enum.
|
||||
- Create: `Emulator/.../habbohotel/items/FurnidataEntryBuilder.java` — maps an `items_base` row → a furnidata JSON5 object string (floor/wall).
|
||||
- Modify: `Emulator/.../messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java` — upsert routing.
|
||||
- Create: `Emulator/src/test/.../FurnidataWriterCreateTest.java` — unit test for create().
|
||||
- Modify (client): `ui/src/components/furni-editor/views/FurniEditorEditView.tsx` — unlock + relabel + re-fetch.
|
||||
- Config: `items.furnidata.create_tier` (default `custom`) read in the handler/writer; documented in the spec.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the furnidata field map (investigation, no code)
|
||||
|
||||
**Files:** read-only.
|
||||
|
||||
- [ ] **Step 1:** Read the exact `items_base` columns: `grep -n "items_base" Emulator/.../habbohotel/items/ItemManager.java` then read the `Item` constructor that consumes `SELECT * FROM items_base` (`Item.java`) to list columns (expected: `id`, `sprite_id`, `public_name`, `item_name`, `width`, `length`, `stack_height`, `allow_stack`, `allow_sit`, `allow_walk`, `allow_lay`, `type`, `interaction_type`, …).
|
||||
- [ ] **Step 2:** Read the renderer floor/wall entry parse to confirm which furnidata fields matter: `renderer/packages/.../FurnitureData.ts` (or wherever `FurnitureDataLoader.parseFloorItems` builds a `FurnitureData`). Note the fields it reads (id, classname, revision, category, name, description, adurl, offerId, buyout, rentOfferId, rentBuyout, bc, excludedDynamic, customParams, specialType, canStandOn, canSitOn, canLayOn, furniLine, environment, rare, + dimensions xdim/ydim).
|
||||
- [ ] **Step 3:** Write the mapping table into this plan file under Task 3 (replace the TABLE-PENDING marker). Mapping (defaults in parens for fields with no items_base source):
|
||||
- `id` ← `items_base.sprite_id` ; `classname` ← `items_base.item_name` ; section `roomitemtypes`(floor)/`wallitemtypes`(wall) ← `items_base.type` (`s`/`i`)
|
||||
- `name` ← submitted name (fallback `public_name`→`item_name`) ; `description` ← submitted desc
|
||||
- `xdim` ← `width` ; `ydim` ← `length` ; `canstandon` ← `allow_walk` ; `cansiton` ← `allow_sit` ; `canlayon` ← `allow_lay`
|
||||
- defaults: `revision`(0) `category`("") `defaultdir`(0) `partcolors`({color:[]}) `offerid`(-1) `buyout`(false) `rentofferid`(-1) `rentbuyout`(false) `bc`(false) `excludeddynamic`(false) `customparams`("") `specialtype`(1) `canlayon` as above `furniline`("") `environment`("") `rare`(false)
|
||||
- [ ] **Step 4:** Commit the locked map: `git commit -am "docs(plan): lock furnidata field map"`
|
||||
|
||||
### Task 2: `FurnidataWriter.create(...)` + unit test (TDD)
|
||||
|
||||
**Files:** Modify `FurnidataWriter.java`; Create `FurnidataWriterCreateTest.java`.
|
||||
|
||||
- [ ] **Step 1: Failing test** — create `FurnidataWriterCreateTest` that: writes a temp single-file furnidata `{ "roomitemtypes": { "furnitype": [ { "id":1, "classname":"old", "name":"Old" } ] }, "wallitemtypes": { "furnitype": [] } }`, calls `writer.create(entryObjectJson5, FurnitureType.FLOOR, /*id*/2, "newcn")`, then reads it back with `FurnidataReader` and asserts BOTH `old` and `newcn` are present, and that the new entry has id 2.
|
||||
|
||||
```java
|
||||
@Test void createAppendsFloorEntryPreservingExisting() throws Exception {
|
||||
Path f = Files.createTempFile("furnidata", ".json5");
|
||||
Files.writeString(f, "{\n // comment\n \"roomitemtypes\": { \"furnitype\": [ { \"id\": 1, \"classname\": \"old\", \"name\": \"Old\" } ] },\n \"wallitemtypes\": { \"furnitype\": [] }\n}");
|
||||
FurnidataWriter w = new FurnidataWriter(f, false, 10_000_000L, 3);
|
||||
String entry = "{ \"id\": 2, \"classname\": \"newcn\", \"name\": \"New\", \"description\": \"\" }";
|
||||
FurnidataWriter.CreateResult r = w.create("newcn", 2, FurnitureType.FLOOR, entry);
|
||||
assertEquals(FurnidataWriter.CreateResult.CREATED, r);
|
||||
var entries = new FurnidataReader(f, 10_000_000L).read();
|
||||
assertTrue(entries.stream().anyMatch(e -> e.classname().equals("old")));
|
||||
assertTrue(entries.stream().anyMatch(e -> e.classname().equals("newcn") && e.id() == 2));
|
||||
assertTrue(Files.readString(f).contains("// comment")); // JSON5 comment preserved
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, expect FAIL** (method missing): `cd Emulator && mvn -q -Dtest=FurnidataWriterCreateTest test` → FAIL/compile error.
|
||||
- [ ] **Step 3: Implement `create()` + `CreateResult`.** Add to `FurnidataWriter`:
|
||||
- `public enum CreateResult { CREATED, ALREADY_EXISTS, ID_COLLISION, NO_TARGET, IO_ERROR }`
|
||||
- `public CreateResult create(String classname, int id, FurnitureType type, String entryObjectJson5)`:
|
||||
1. `cn = classname.trim().toLowerCase`. Scan all entries via `FurnidataReader(allFiles).read()`: if any entry has `cn` → return `ALREADY_EXISTS`; if any entry has the same `id` but a different classname → return `ID_COLLISION`.
|
||||
2. Resolve target file: single-file → `source`; split-tier → the configured create tier file (passed in via a `Path targetFile` arg OR resolved here from `items.furnidata.create_tier`; the handler passes the resolved tier dir's first file). If none → `NO_TARGET` (or create the file with a shell — see Step 3b).
|
||||
3. Section key = `roomitemtypes` (FLOOR) / `wallitemtypes` (WALL).
|
||||
4. Read raw; locate `"<section>"` → its `"furnitype"` → the `[` … `]` array (reuse `matchingClose`/brace helpers, string-aware). Insert the entry object: if array empty → `[ <entry> ]`; else insert `, <entry>` before the closing `]` (preserve indentation). If the section/array is absent in the target file, synthesize it (e.g. add `"roomitemtypes": { "furnitype": [ <entry> ] }` into the root object).
|
||||
5. `backup(target)` + `atomicWrite(target, edited)`; return `CREATED`. Wrap IO in try/catch → `IO_ERROR`.
|
||||
- Reuse existing `matchingClose`, `lastUnbalancedBrace`, `jsonEscape`, `backup`, `atomicWrite`.
|
||||
- [ ] **Step 3b:** Add a helper to find the array insertion point: `static int furnitypeArrayClose(String raw, String section)` returning the index of the `]` that closes `<section>.furnitype`, or -1 if absent. String-aware brace/bracket scan starting from the section key match.
|
||||
- [ ] **Step 4: Run, expect PASS.** Add a 2nd test for `ALREADY_EXISTS` (create "old") and a 3rd for `ID_COLLISION` (create classname "x" with id 1). `mvn -q -Dtest=FurnidataWriterCreateTest test` → PASS.
|
||||
- [ ] **Step 5: Commit** `git commit -am "feat(furnidata): FurnidataWriter.create — append new entry (JSON5-preserving)"`
|
||||
|
||||
### Task 3: `FurnidataEntryBuilder` (items_base row → entry JSON5 string)
|
||||
|
||||
**Files:** Create `FurnidataEntryBuilder.java`.
|
||||
|
||||
Mapping table: **(filled by Task 1 Step 3)**
|
||||
|
||||
- [ ] **Step 1:** Implement `static String build(ResultSet itemsBaseRow, String name, String description)` (or take a typed struct) that returns a JSON5 object string with the mapped fields (use `jsonEscape` for strings; booleans/ints inline). Floor vs wall determined by caller; this just emits the object. Keep field order matching existing entries for readability.
|
||||
- [ ] **Step 2:** Unit test: feed a fake row (or a small struct), assert the output string parses (Gson) and has `id`, `classname`, `name`, `xdim`, `ydim`, `canstandon`. `mvn -q -Dtest=FurnidataEntryBuilderTest test` → PASS.
|
||||
- [ ] **Step 3: Commit** `git commit -am "feat(furnidata): items_base → furnidata entry builder"`
|
||||
|
||||
### Task 4: Handler upsert — `FurniEditorUpdateFurnidataEvent`
|
||||
|
||||
**Files:** Modify `FurniEditorUpdateFurnidataEvent.java`.
|
||||
|
||||
- [ ] **Step 1:** Before `writer.write(...)` (line ~122), check existence: `boolean exists = provider.getName(classname) != null || furnidataHasClassname(provider, classname)`. (Add a small helper that reads the source via `FurnidataReader` and checks the classname, since `getName` returns null for entries with empty names too.)
|
||||
- [ ] **Step 2:** If `exists` → keep current `write()` path (audit action `"edit"`).
|
||||
- [ ] **Step 3:** Else (missing) → resolve the full `items_base` row for `itemId` (extend `classnameForItem` into a `loadItemBaseRow(itemId)` returning sprite_id/type/width/length/flags/public_name + classname). Determine `FurnitureType` from `type`. Build the entry via `FurnidataEntryBuilder.build(row, nameOrPublic, desc)`. Resolve target tier (config `items.furnidata.create_tier`, default `custom`; for single-file the writer ignores it). Call `writer.create(classname, spriteId, type, entryJson5)`. Map `CreateResult` → success/precise error message (`ALREADY_EXISTS`→fall back to edit; `ID_COLLISION`→"id N already used"; etc.). On `CREATED`: same post-steps as edit (`reindexFromSource` + broadcast 10047 + mirror public_name + audit action `"create"`).
|
||||
- [ ] **Step 4:** Build the jar: `cd Emulator && mvn -q clean package -DskipTests` → BUILD SUCCESS, note the produced `target/Habbo-*.jar`.
|
||||
- [ ] **Step 5: Commit** `git commit -am "feat(furni-editor): upsert — create furnidata entry when classname missing (10046)"`
|
||||
|
||||
### Task 5: Client — unlock + relabel + re-fetch
|
||||
|
||||
**Files:** Modify `ui/src/components/furni-editor/views/FurniEditorEditView.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Change the `furnidataEditable` memo (line ~240) so a `null` entry no longer hard-locks: when `furniDataEntry === null`, return `true` (editable → will create). Keep the existing classname-mismatch lock for the present-but-mismatched case.
|
||||
- [ ] **Step 2:** Replace the warning block (lines ~401-405) with an informational note when `furniDataEntry === null`: "No furnidata entry yet — saving will create one." Prefill the name input from `item.publicName` when entry is null and the field is empty.
|
||||
- [ ] **Step 3:** Relabel the Save button to "Create entry" when `furniDataEntry === null`, else "Save name/desc".
|
||||
- [ ] **Step 4:** On `FurniEditorResultEvent` success, re-send `FurniEditorDetailComposer(item.id)` so `furniDataEntry` repopulates (verify the success handler already refetches; if not, add it).
|
||||
- [ ] **Step 5:** `yarn --cwd E:/Users/simol/Desktop/DEV/ui typecheck` → clean. **Commit** on a client branch (NOT mixed with PR #236): `git checkout -b feat/furni-editor-create-missing origin/Dev` first, cherry-pick this file's change, commit `feat(furni-editor): create furnidata entry when missing (upsert Save)`.
|
||||
|
||||
### Task 6: Runtime verification (Chrome handle)
|
||||
|
||||
- [ ] **Step 1:** Restart the emulator with the new jar (the user runs it / `emulatore.bat`). Reload `localhost:5173`.
|
||||
- [ ] **Step 2:** Open Furni Editor on a furni with NO furnidata entry (the "DB fallback" case). Confirm name/desc now editable + button reads "Create entry".
|
||||
- [ ] **Step 3:** Type a name, Save. Expect: success result; console shows 10046 sent + 10047 (FurnitureDataReload) broadcast; the furni's name updates live; reopening the editor shows the entry now present (editable normally).
|
||||
- [ ] **Step 4:** Verify on disk: the new object appears in `Nitro-Files/nitro-assets/gamedata/FurnitureData.json` under the right section, with the mapped fields, and `FurnidataReader` parses the file (no corruption; a `.bak` was made).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
- Spec coverage: upsert trigger (T4/T5), complete entry from items_base (T3), config tier (T4), id=sprite + collision guard (T2/T4), no renderer change (none here), error cases (T2 CreateResult + T4 mapping), tests (T2/T3 unit + T6 runtime). Covered.
|
||||
- Field map exact column names are locked in Task 1 before any code consumes them (not a placeholder — an explicit investigation task).
|
||||
- Config key aligned to existing prefix: `items.furnidata.create_tier`.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Furni Editor — create furnidata entry if missing (upsert)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Approved design → implementation
|
||||
**Repos:** Arcturus-Morningstar-Extended (emulator, primary), Nitro-V3 (client, minor)
|
||||
|
||||
## Problem
|
||||
|
||||
In the in-client **Furni Editor**, many furni have **no matching entry in the
|
||||
furnidata** (split-tier `*.json5` files). Today the editor detects this
|
||||
(`furniDataEntry === null`), shows a "Public Name (DB fallback)" and **locks**
|
||||
the name/description fields with the warning "this furni has no matching
|
||||
furnidata entry … so its display name can't be edited here."
|
||||
`FurnidataWriter.write()` is **edit-only** — it refuses classnames absent from
|
||||
furnidata. There is no path to **create** the missing entry.
|
||||
|
||||
Goal: let an operator create the missing furnidata entry directly from the
|
||||
editor, so the furni gets a real, editable name/description.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **Trigger = upsert Save.** When the entry is missing, the name/desc fields
|
||||
are *unlocked* (name prefilled from the DB Public Name); the existing "Save
|
||||
name/desc" creates the entry if absent, edits it if present. No separate
|
||||
button beyond a relabel ("Create entry" when missing).
|
||||
2. **Completeness = full entry seeded from `items_base`.** The created entry is
|
||||
a complete furnidata object (structural fields read from the item's DB row),
|
||||
not a name-only stub.
|
||||
3. **Target = config key `furnidata.editor.create_tier` (default `custom`).**
|
||||
Split-tier → that tier file; single-file furnidata → the single file.
|
||||
|
||||
## Approach
|
||||
|
||||
**Reuse the existing `FurniEditorUpdateFurnidata` packet** (outgoing header
|
||||
`10046`, result `10044`) and make the **server handler upsert**. Rejected
|
||||
alternative: a dedicated `Create` packet (10050) — unnecessary, because the
|
||||
create needs **no extra client-supplied fields** (the server reads `items_base`
|
||||
for the structural fields and takes name/desc from the existing 10046 payload).
|
||||
|
||||
**Net wire impact: none.** No renderer changes, no new packet. Only:
|
||||
- Emulator: a new `FurnidataWriter.create(...)` + the 10046 handler becomes
|
||||
upsert + one config key + an `items_base → furnidata` field mapper.
|
||||
- Client: unlock the name/desc fields when the entry is missing + relabel Save.
|
||||
|
||||
## Emulator changes (Java)
|
||||
|
||||
### 1. `habbohotel/items/FurnidataWriter.create(...)`
|
||||
New method, mirrors `write()`'s safety (locate target file, **backup +
|
||||
atomic write**, preserve JSON5 formatting/comments):
|
||||
- Resolve target file: read config `furnidata.editor.create_tier` (default
|
||||
`custom`). If split-tier (manifest present) → that tier's file (create the
|
||||
file with a valid empty-array JSON5 shell if it doesn't exist yet). If
|
||||
single-file furnidata → the single file.
|
||||
- Append a complete entry object (see field mapping) to the correct array
|
||||
(`roomitemtypes` for floor / `wallitemtypes` for wall).
|
||||
- **Guards:** refuse if the classname already exists anywhere in furnidata
|
||||
(caller routes to edit instead); refuse if the chosen `id` (sprite id) is
|
||||
already used by a *different* classname (id collision would break
|
||||
`roomItem.name.{id}` / `getFloorItemData(typeId)` resolution).
|
||||
- Return a result enum/boolean (created / already-exists / id-collision /
|
||||
io-error) so the handler can message the operator precisely.
|
||||
|
||||
### 2. `FurniEditorUpdateFurnidataEvent` (header 10046) → upsert
|
||||
- Resolve classname + the full `items_base` row from `itemId` (handler already
|
||||
resolves classname).
|
||||
- If furnidata **has** the classname → existing edit path (`write()`).
|
||||
- Else → build the complete entry from `items_base` + submitted name/desc →
|
||||
`FurnidataWriter.create(...)`.
|
||||
- After either path (unchanged from edit): `FurnitureTextProvider.reindexFromSource()`,
|
||||
broadcast `FurnitureDataReloadComposer` (10047), mirror name into
|
||||
`items_base.public_name`, audit log (action `"create"` vs `"edit"`), respond
|
||||
`FurniEditorResultComposer` (10044) with success/precise error.
|
||||
- Permission `ACC_CATALOGFURNI` + 1000ms rate-limit (unchanged).
|
||||
|
||||
### 3. Config key
|
||||
`furnidata.editor.create_tier` (default `custom`), read where the writer
|
||||
resolves the target file.
|
||||
|
||||
### 4. `items_base → furnidata` field mapping (helper)
|
||||
Read the item's DB definition and map to furnidata JSON. Minimum complete set
|
||||
(exact column/field names verified during implementation against the
|
||||
`FurnidataReader` schema + `items_base`):
|
||||
- `id` = item **sprite id** (the visual/type id — MUST match so the furni
|
||||
resolves its name/data), `classname` = `item_name`,
|
||||
- `type` = `"s"` (floor) / `"i"` (wall) from the item type,
|
||||
- `name` = submitted name (fallback: public_name → classname), `description` =
|
||||
submitted description,
|
||||
- `xdim`/`ydim` = width/length, `canstandon`/`cansiton`/`canlayon` from the
|
||||
item's stand/sit/lay flags, plus the standard furnidata defaults for the
|
||||
remaining fields (`partcolors`, `offerid = -1`, `buyout`, `bc`,
|
||||
`excludeddynamic`, `customparams`, `specialtype`, `furniline`,
|
||||
`environment`, `rare`, `revision`, `category`).
|
||||
|
||||
## Client changes (React) — `FurniEditorEditView.tsx`
|
||||
|
||||
- When `furniDataEntry === null`: **unlock** the name/description inputs
|
||||
(currently gated by the `furnidataEditable` memo), prefill name from
|
||||
`item.publicName`, description blank. Replace the "can't be edited here"
|
||||
warning with an informational note: "No furnidata entry yet — saving will
|
||||
create one in the «custom» tier." Relabel the Save button to "Create entry"
|
||||
while missing.
|
||||
- The Save handler is unchanged — it already sends
|
||||
`FurniEditorUpdateFurnidataComposer(itemId, { name, description })`.
|
||||
- On `FurniEditorResultEvent` success, re-fetch detail
|
||||
(`FurniEditorDetailComposer(itemId)`) so `furniDataEntry` populates and the UI
|
||||
flips to normal edit mode.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Save (entry missing)
|
||||
→ 10046 UpdateFurnidata(itemId, {name, desc})
|
||||
→ handler: classname absent → build complete entry from items_base + name/desc
|
||||
→ FurnidataWriter.create(...) into the custom tier (atomic + backup)
|
||||
→ reindexFromSource() + broadcast 10047 FurnitureDataReload
|
||||
→ every client's catalog/inventory/infostand refreshes; the rendered
|
||||
furni now resolves its real name
|
||||
→ mirror items_base.public_name
|
||||
→ audit "create"
|
||||
→ 10044 result(success)
|
||||
→ client re-fetches detail → entry now present → normal edit mode
|
||||
```
|
||||
|
||||
## Error handling / edge cases
|
||||
|
||||
- Classname already present (lookup race) → routed to edit (upsert).
|
||||
- Sprite id already used by a different classname → refuse + "id N already
|
||||
used by classname X".
|
||||
- `items_base` row missing → refuse + error (shouldn't happen for a known item).
|
||||
- Tier file absent → created with a valid JSON5 shell.
|
||||
- Empty submitted name → fall back to public_name, else classname.
|
||||
- Concurrency: reuse `write()`'s file lock + atomic write + backup.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Emulator unit:** `FurnidataWriter.create` writes a valid JSON5 entry into
|
||||
the target tier; idempotency guard (already-exists); id-collision guard;
|
||||
round-trips through `FurnidataReader`.
|
||||
- **Runtime (Chrome handle available):** in the Furni Editor select a furni
|
||||
with no furnidata entry (the live "DB fallback" case), type a name, Save →
|
||||
entry created, furni name updates live (10047 broadcast), reopen → entry
|
||||
present and editable. Verify the new object lands in the `custom` tier file
|
||||
and `FurnidataReader` parses it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No new wire packet; no renderer changes.
|
||||
- No bulk/batch creation; one furni at a time via the editor.
|
||||
- No editing of structural fields from the UI (only name/desc, as today); the
|
||||
structural fields are seeded once at creation from `items_base`.
|
||||
- No deletion of furnidata entries (separate concern).
|
||||
Reference in New Issue
Block a user