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 pull request #108 from Lorenzune/merge-duckie-main-2026-05-06
Add badge leaderboard API endpoint
This commit is contained in:
@@ -0,0 +1,477 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Live required schema
|
||||||
|
-- ============================================================
|
||||||
|
-- Consolidated schema for the currently used Nitro/Arcturus live
|
||||||
|
-- additions. This file intentionally excludes old/unused migration
|
||||||
|
-- artifacts and dump-only data.
|
||||||
|
--
|
||||||
|
-- Scope:
|
||||||
|
-- - tables/columns currently referenced by Java code
|
||||||
|
-- - runtime settings required by secure assets/API, login, wired, and UI
|
||||||
|
-- - safe CREATE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS statements
|
||||||
|
--
|
||||||
|
-- Assumes the base Arcturus database already exists.
|
||||||
|
-- Tested for MariaDB-style syntax used by this project.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Core settings support
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE `emulator_settings`
|
||||||
|
ADD COLUMN IF NOT EXISTS `comment` TEXT NULL DEFAULT '' AFTER `value`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `wired_emulator_settings` (
|
||||||
|
`key` VARCHAR(255) NOT NULL,
|
||||||
|
`value` TEXT NOT NULL,
|
||||||
|
`comment` TEXT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (`key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO `emulator_settings` (`key`, `value`) VALUES
|
||||||
|
('crypto.ws.enabled', '0'),
|
||||||
|
('crypto.ws.signing.enabled', '0'),
|
||||||
|
('crypto.ws.signing.public_key', ''),
|
||||||
|
('crypto.ws.signing.private_key', ''),
|
||||||
|
('login.access.jwt.secret', ''),
|
||||||
|
('login.remember.duration.days', '30'),
|
||||||
|
('login.remember.rotate.interval.minutes', '15'),
|
||||||
|
('login.remember.jwt.secret', ''),
|
||||||
|
('login.turnstile.enabled', '0'),
|
||||||
|
('login.turnstile.sitekey', ''),
|
||||||
|
('login.turnstile.secretkey', ''),
|
||||||
|
('login.ratelimit.enabled', '1'),
|
||||||
|
('login.ratelimit.max_attempts', '5'),
|
||||||
|
('login.ratelimit.window_sec', '60'),
|
||||||
|
('login.ratelimit.lockout_sec', '120'),
|
||||||
|
('login.register.enabled', '1'),
|
||||||
|
('register.max_per_ip', '5'),
|
||||||
|
('register.default.look', 'hr-100-7.hd-180-1.ch-210-66.lg-270-82.sh-290-80'),
|
||||||
|
('register.default.motto', 'I love Habbo!'),
|
||||||
|
('password.reset.url', 'http://localhost/reset-password'),
|
||||||
|
('smtp.provider', 'own'),
|
||||||
|
('smtp.host', 'localhost'),
|
||||||
|
('smtp.port', '587'),
|
||||||
|
('smtp.username', ''),
|
||||||
|
('smtp.password', ''),
|
||||||
|
('smtp.from_address', 'no-reply@example.com'),
|
||||||
|
('smtp.from_name', 'Habbo Hotel'),
|
||||||
|
('smtp.use_tls', '1'),
|
||||||
|
('smtp.use_ssl', '0'),
|
||||||
|
('new_user_credits', '0'),
|
||||||
|
('new_user_duckets', '0'),
|
||||||
|
('new_user_diamonds', '0')
|
||||||
|
ON DUPLICATE KEY UPDATE `value` = `value`;
|
||||||
|
|
||||||
|
INSERT INTO `wired_emulator_settings` (`key`, `value`, `comment`) VALUES
|
||||||
|
('wired.engine.enabled', '1', 'Compatibility flag. The runtime uses the new wired engine.'),
|
||||||
|
('wired.engine.exclusive', '1', 'Compatibility flag. The runtime uses exclusive wired engine execution.'),
|
||||||
|
('wired.engine.maxStepsPerStack', '100', 'Maximum internal processing steps allowed for a single wired stack execution.'),
|
||||||
|
('wired.engine.debug', '0', 'Enable verbose debug logging for the wired engine.'),
|
||||||
|
('wired.custom.enabled', '0', 'Enable custom legacy wired compatibility behavior.'),
|
||||||
|
('hotel.wired.furni.selection.count', '5', 'Maximum number of furni that a wired box can store or select.'),
|
||||||
|
('hotel.wired.max_delay', '20', 'Maximum delay value accepted by wired effects that support delayed execution.'),
|
||||||
|
('hotel.wired.message.max_length', '512', 'Maximum length of wired message text fields.'),
|
||||||
|
('wired.effect.teleport.delay', '500', 'Delay in milliseconds used by wired teleport movement.'),
|
||||||
|
('wired.place.under', '0', 'Allow placing wired furniture underneath other items when room rules permit it.'),
|
||||||
|
('wired.tick.interval.ms', '50', 'Global wired tick interval in milliseconds.'),
|
||||||
|
('wired.tick.resolution', '100', 'Legacy wired tick resolution value.'),
|
||||||
|
('wired.tick.debug', '0', 'Enable verbose logging for the wired tick service.'),
|
||||||
|
('wired.tick.thread.priority', '6', 'Java thread priority used by the wired tick service.'),
|
||||||
|
('wired.highscores.displaycount', '25', 'Maximum number of wired highscore entries shown to users.'),
|
||||||
|
('wired.abuse.max.recursion.depth', '10', 'Maximum recursive wired depth before execution is stopped.'),
|
||||||
|
('wired.abuse.max.events.per.window', '100', 'Maximum identical wired events allowed inside the abuse rate-limit window.'),
|
||||||
|
('wired.abuse.rate.limit.window.ms', '10000', 'Wired abuse rate-limit window in milliseconds.'),
|
||||||
|
('wired.abuse.ban.duration.ms', '600000', 'Temporary wired ban duration after abuse detection.'),
|
||||||
|
('wired.monitor.usage.window.ms', '1000', 'Rolling window size for wired usage monitoring.'),
|
||||||
|
('wired.monitor.usage.limit', '1000', 'Maximum wired usage budget in one monitor window.'),
|
||||||
|
('wired.monitor.delayed.events.limit', '100', 'Maximum delayed wired events queued in one room.'),
|
||||||
|
('wired.monitor.overload.average.ms', '50', 'Average execution time threshold for overload tracking.'),
|
||||||
|
('wired.monitor.overload.peak.ms', '150', 'Peak execution time threshold for overload tracking.'),
|
||||||
|
('wired.monitor.overload.consecutive.windows', '2', 'Consecutive overloaded windows required before logging overload.'),
|
||||||
|
('wired.monitor.heavy.usage.percent', '70', 'Usage percentage threshold for heavy-room tracking.'),
|
||||||
|
('wired.monitor.heavy.consecutive.windows', '5', 'Consecutive windows above heavy usage threshold.'),
|
||||||
|
('wired.monitor.heavy.delayed.percent', '60', 'Delayed queue percentage threshold for heavy-room tracking.')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`value` = VALUES(`value`),
|
||||||
|
`comment` = VALUES(`comment`);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Login API, room templates, remember-me, and news
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `password_resets` (
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`token` VARCHAR(128) NOT NULL,
|
||||||
|
`expires_at` TIMESTAMP NOT NULL,
|
||||||
|
`created_ip` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (`user_id`),
|
||||||
|
UNIQUE KEY `idx_password_resets_token` (`token`),
|
||||||
|
CONSTRAINT `fk_password_resets_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `users_remember_families` (
|
||||||
|
`family_id` CHAR(36) NOT NULL,
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`current_version` INT(11) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` INT(11) NOT NULL,
|
||||||
|
`expires_at` INT(11) NOT NULL,
|
||||||
|
`revoked` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`last_ip` VARCHAR(45) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (`family_id`),
|
||||||
|
KEY `idx_users_remember_families_user_id` (`user_id`),
|
||||||
|
KEY `idx_users_remember_families_expires_at` (`expires_at`),
|
||||||
|
CONSTRAINT `fk_users_remember_families_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_templates` (
|
||||||
|
`template_id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`title` VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
`description` VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
`thumbnail` VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
`sort_order` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`enabled` ENUM('0','1') NOT NULL DEFAULT '1',
|
||||||
|
`name` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`room_description` VARCHAR(250) NOT NULL DEFAULT '',
|
||||||
|
`model` VARCHAR(100) NOT NULL,
|
||||||
|
`password` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`state` ENUM('open','locked','password','invisible') NOT NULL DEFAULT 'open',
|
||||||
|
`users_max` INT(11) NOT NULL DEFAULT 25,
|
||||||
|
`category` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`paper_floor` VARCHAR(50) NOT NULL DEFAULT '0.0',
|
||||||
|
`paper_wall` VARCHAR(50) NOT NULL DEFAULT '0.0',
|
||||||
|
`paper_landscape` VARCHAR(50) NOT NULL DEFAULT '0.0',
|
||||||
|
`thickness_wall` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`thickness_floor` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`moodlight_data` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
|
`override_model` ENUM('0','1') NOT NULL DEFAULT '0',
|
||||||
|
`trade_mode` INT(2) NOT NULL DEFAULT 2,
|
||||||
|
`heightmap` MEDIUMTEXT NOT NULL,
|
||||||
|
`door_x` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`door_y` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`door_dir` INT(4) NOT NULL DEFAULT 2,
|
||||||
|
PRIMARY KEY (`template_id`),
|
||||||
|
KEY `idx_room_templates_enabled_sort` (`enabled`, `sort_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_templates_items` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`template_id` INT(11) NOT NULL,
|
||||||
|
`item_id` INT(11) UNSIGNED NOT NULL,
|
||||||
|
`wall_pos` VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
|
`x` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`y` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`z` DOUBLE(10,6) NOT NULL DEFAULT 0.000000,
|
||||||
|
`rot` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`extra_data` VARCHAR(2096) NOT NULL DEFAULT '',
|
||||||
|
`wired_data` VARCHAR(4096) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_room_templates_items_template_id` (`template_id`),
|
||||||
|
KEY `idx_room_templates_items_item_id` (`item_id`),
|
||||||
|
CONSTRAINT `fk_room_templates_items_template`
|
||||||
|
FOREIGN KEY (`template_id`) REFERENCES `room_templates` (`template_id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_room_templates_items_item_base`
|
||||||
|
FOREIGN KEY (`item_id`) REFERENCES `items_base` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `ui_news` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`title` VARCHAR(150) NOT NULL,
|
||||||
|
`body` TEXT NOT NULL,
|
||||||
|
`image` MEDIUMTEXT DEFAULT NULL,
|
||||||
|
`link_text` VARCHAR(80) NOT NULL DEFAULT '',
|
||||||
|
`link_url` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`sort_order` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ui_news_enabled_sort` (`enabled`, `sort_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
|
INSERT INTO `ui_news` (`title`, `body`, `image`, `link_text`, `link_url`, `enabled`, `sort_order`)
|
||||||
|
SELECT 'Welcome to the Hotel!', 'Catch up on the latest events, updates and competitions happening right now in the hotel.', '', '', '', 1, 0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `ui_news`);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Wired runtime data
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_wired_settings` (
|
||||||
|
`room_id` INT(11) NOT NULL,
|
||||||
|
`inspect_mask` INT(11) NOT NULL DEFAULT 0 COMMENT 'Bitmask for who can open and inspect Wired in the room. 1=everyone, 2=users with rights, 4=group members, 8=group admins.',
|
||||||
|
`modify_mask` INT(11) NOT NULL DEFAULT 0 COMMENT 'Bitmask for who can modify Wired in the room. 2=users with rights, 4=group members, 8=group admins.',
|
||||||
|
PRIMARY KEY (`room_id`),
|
||||||
|
CONSTRAINT `fk_room_wired_settings_room`
|
||||||
|
FOREIGN KEY (`room_id`) REFERENCES `rooms` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_wired_variables` (
|
||||||
|
`room_id` INT(11) NOT NULL,
|
||||||
|
`variable_item_id` INT(11) NOT NULL,
|
||||||
|
`value` INT(11) DEFAULT NULL,
|
||||||
|
`created_at` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`room_id`, `variable_item_id`),
|
||||||
|
KEY `idx_room_wired_variables_room_item` (`room_id`, `variable_item_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_user_wired_variables` (
|
||||||
|
`room_id` INT(11) NOT NULL,
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`variable_item_id` INT(11) NOT NULL,
|
||||||
|
`value` INT(11) DEFAULT NULL,
|
||||||
|
`created_at` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`room_id`, `user_id`, `variable_item_id`),
|
||||||
|
KEY `idx_room_user_wired_variables_room_item` (`room_id`, `variable_item_id`),
|
||||||
|
KEY `idx_room_user_wired_variables_user` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_furni_wired_variables` (
|
||||||
|
`room_id` INT(11) NOT NULL,
|
||||||
|
`furni_id` INT(11) NOT NULL,
|
||||||
|
`variable_item_id` INT(11) NOT NULL,
|
||||||
|
`value` INT(11) DEFAULT NULL,
|
||||||
|
`created_at` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`room_id`, `furni_id`, `variable_item_id`),
|
||||||
|
KEY `idx_room_furni_wired_variables_room_item` (`room_id`, `variable_item_id`),
|
||||||
|
KEY `idx_room_furni_wired_variables_furni` (`furni_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- User customization: prefixes, nick icons, profile backgrounds
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE `users`
|
||||||
|
ADD COLUMN IF NOT EXISTS `background_id` INT(11) NOT NULL DEFAULT 0 AFTER `machine_id`,
|
||||||
|
ADD COLUMN IF NOT EXISTS `background_stand_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_id`,
|
||||||
|
ADD COLUMN IF NOT EXISTS `background_overlay_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_stand_id`,
|
||||||
|
ADD COLUMN IF NOT EXISTS `background_card_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_overlay_id`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `infostand_backgrounds` (
|
||||||
|
`id` INT(11) NOT NULL,
|
||||||
|
`category` ENUM('background','stand','overlay','card') NOT NULL,
|
||||||
|
`min_rank` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`is_hc_only` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`is_ambassador_only` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`, `category`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `infostand_backgrounds` (`id`, `category`, `min_rank`, `is_hc_only`, `is_ambassador_only`) VALUES
|
||||||
|
(0, 'background', 0, 0, 0),
|
||||||
|
(0, 'stand', 0, 0, 0),
|
||||||
|
(0, 'overlay', 0, 0, 0),
|
||||||
|
(0, 'card', 0, 0, 0);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_prefixes` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`text` VARCHAR(50) NOT NULL,
|
||||||
|
`color` VARCHAR(255) NOT NULL DEFAULT '#FFFFFF',
|
||||||
|
`icon` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`effect` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`font` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`catalog_prefix_id` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`display_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`points` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`points_type` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`is_custom` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`active` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_user_prefixes_user_id` (`user_id`),
|
||||||
|
KEY `idx_user_prefixes_user_active` (`user_id`, `active`),
|
||||||
|
CONSTRAINT `fk_user_prefixes_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_prefixes_catalog` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`display_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`text` VARCHAR(50) NOT NULL,
|
||||||
|
`color` VARCHAR(255) NOT NULL DEFAULT '#FFFFFF',
|
||||||
|
`icon` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`effect` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`font` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`points` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`points_type` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`sort_order` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_visual_settings` (
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`display_order` VARCHAR(50) NOT NULL DEFAULT 'icon-prefix-name',
|
||||||
|
PRIMARY KEY (`user_id`),
|
||||||
|
CONSTRAINT `fk_user_visual_settings_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_prefix_settings` (
|
||||||
|
`key_name` VARCHAR(100) NOT NULL,
|
||||||
|
`value` VARCHAR(255) NOT NULL,
|
||||||
|
PRIMARY KEY (`key_name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_prefix_blacklist` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`word` VARCHAR(100) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_custom_prefix_blacklist_word` (`word`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `custom_prefix_settings` (`key_name`, `value`) VALUES
|
||||||
|
('max_length', '15'),
|
||||||
|
('min_rank_to_buy', '1'),
|
||||||
|
('price_credits', '5'),
|
||||||
|
('price_points', '0'),
|
||||||
|
('points_type', '0'),
|
||||||
|
('font_price_credits', '10'),
|
||||||
|
('font_price_points', '0'),
|
||||||
|
('font_points_type', '0');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `custom_prefixes_catalog`
|
||||||
|
(`id`, `display_name`, `text`, `color`, `icon`, `effect`, `font`, `points`, `points_type`, `enabled`, `sort_order`)
|
||||||
|
VALUES
|
||||||
|
(1, 'VIP', 'VIP', '#FFD700', '', 'glow', '', 10, 0, 1, 1),
|
||||||
|
(2, 'Legend', 'Legend', '#8B5CF6', '', 'discord-neon', '', 15, 0, 1, 2),
|
||||||
|
(3, 'Staff Pick', 'Staff', '#3B82F6', '*', 'cartoon', '', 20, 0, 1, 3);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_nick_icons_catalog` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`icon_key` VARCHAR(50) NOT NULL,
|
||||||
|
`display_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`points` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`points_type` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`sort_order` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_custom_nick_icons_catalog_icon_key` (`icon_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_nick_icons` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`icon_key` VARCHAR(50) NOT NULL,
|
||||||
|
`active` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_nick_icons_user_icon` (`user_id`, `icon_key`),
|
||||||
|
KEY `idx_user_nick_icons_user_id` (`user_id`),
|
||||||
|
KEY `idx_user_nick_icons_user_active` (`user_id`, `active`),
|
||||||
|
CONSTRAINT `fk_user_nick_icons_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `custom_nick_icons_catalog` (`icon_key`, `display_name`, `points`, `points_type`, `enabled`, `sort_order`) VALUES
|
||||||
|
('1', 'Icon 1', 10, 0, 1, 1),
|
||||||
|
('2', 'Icon 2', 10, 0, 1, 2),
|
||||||
|
('3', 'Icon 3', 10, 0, 1, 3),
|
||||||
|
('4', 'Icon 4', 10, 0, 1, 4),
|
||||||
|
('5', 'Icon 5', 10, 0, 1, 5),
|
||||||
|
('6', 'Icon 6', 10, 0, 1, 6);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Custom badge maker
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `users_custom_badge_settings` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`badge_path` VARCHAR(255) NOT NULL DEFAULT '/var/www/gamedata/c_images/album1584',
|
||||||
|
`badge_url` VARCHAR(255) NOT NULL DEFAULT '/gamedata/c_images/album1584',
|
||||||
|
`price_badge` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`currency_type` INT(11) NOT NULL DEFAULT -1,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
|
INSERT INTO `users_custom_badge_settings` (`id`, `badge_path`, `badge_url`, `price_badge`, `currency_type`)
|
||||||
|
SELECT 1, '/var/www/gamedata/c_images/album1584', '/gamedata/c_images/album1584', 50, 5
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `users_custom_badge_settings` WHERE `id` = 1);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_custom_badge` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT(11) NOT NULL,
|
||||||
|
`badge_id` VARCHAR(64) NOT NULL,
|
||||||
|
`badge_name` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`badge_description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`date_created` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`date_edit` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_custom_badge_badge_id` (`badge_id`),
|
||||||
|
KEY `idx_user_custom_badge_user_id` (`user_id`),
|
||||||
|
CONSTRAINT `fk_user_custom_badge_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- UI/catalog compatibility values used by the current client
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO `chat_bubbles` (`type`, `name`, `permission`, `overridable`, `triggers_talking_furniture`) VALUES
|
||||||
|
(200, 'SHOW_MESSAGE_RED', '', 1, 0),
|
||||||
|
(201, 'SHOW_MESSAGE_GREEN', '', 1, 0),
|
||||||
|
(202, 'SHOW_MESSAGE_BLUE', '', 1, 0),
|
||||||
|
(210, 'SHOW_MESSAGE_ALERT', '', 1, 0),
|
||||||
|
(211, 'SHOW_MESSAGE_INFO', '', 1, 0),
|
||||||
|
(212, 'SHOW_MESSAGE_WARNING', '', 1, 0),
|
||||||
|
(220, 'SHOW_MESSAGE_WRONG', '', 1, 0),
|
||||||
|
(221, 'SHOW_MESSAGE_WRONG_CIRCLED', '', 1, 0),
|
||||||
|
(222, 'SHOW_MESSAGE_CORRECT', '', 1, 0),
|
||||||
|
(223, 'SHOW_MESSAGE_CORRECT_CIRCLED', '', 1, 0),
|
||||||
|
(224, 'SHOW_MESSAGE_QUESTION', '', 1, 0),
|
||||||
|
(225, 'SHOW_MESSAGE_QUESTION_CIRCLED', '', 1, 0),
|
||||||
|
(226, 'SHOW_MESSAGE_ARROW_UP', '', 1, 0),
|
||||||
|
(227, 'SHOW_MESSAGE_ARROW_UP_CIRCLED', '', 1, 0),
|
||||||
|
(228, 'SHOW_MESSAGE_ARROW_DOWN', '', 1, 0),
|
||||||
|
(229, 'SHOW_MESSAGE_ARROW_DOWN_CIRCLED', '', 1, 0),
|
||||||
|
(250, 'SHOW_MESSAGE_SKULL', '', 1, 0),
|
||||||
|
(251, 'SHOW_MESSAGE_SKULL_ALT', '', 1, 0),
|
||||||
|
(252, 'SHOW_MESSAGE_MAGNIFIER', '', 1, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`name` = VALUES(`name`),
|
||||||
|
`permission` = VALUES(`permission`),
|
||||||
|
`overridable` = VALUES(`overridable`),
|
||||||
|
`triggers_talking_furniture` = VALUES(`triggers_talking_furniture`);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
||||||
|
('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).'),
|
||||||
|
('commands.error.cmd_setroom_template', 'Could not save room as template. Check the server log for details.'),
|
||||||
|
('commands.error.cmd_setroom_template.no_room', 'You must be inside a room to use this command.'),
|
||||||
|
('commands.keys.cmd_give_prefix', 'giveprefix'),
|
||||||
|
('commands.keys.cmd_list_prefixes', 'listprefixes'),
|
||||||
|
('commands.keys.cmd_remove_prefix', 'removeprefix'),
|
||||||
|
('commands.keys.cmd_prefix_blacklist', 'prefixblacklist'),
|
||||||
|
('wiredfurni.badgereceived.body', 'You have just received a new Badge! Check your Inventory!'),
|
||||||
|
('wiredfurni.badgereceived.title', 'Badge received!');
|
||||||
|
|
||||||
|
-- Optional permission metadata for normalized permission schemas.
|
||||||
|
-- Actual rank values still belong in the permissions/permission_ranks setup.
|
||||||
|
CREATE TABLE IF NOT EXISTS `permission_definitions` (
|
||||||
|
`permission_key` VARCHAR(64) NOT NULL,
|
||||||
|
`max_value` TINYINT(3) UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
`comment` TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (`permission_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`) VALUES
|
||||||
|
('cmd_setroom_template', 1, 'Allows using :setroom_template to copy a room into the login room-template table.'),
|
||||||
|
('cmd_give_prefix', 1, 'Allows granting custom prefixes to users.'),
|
||||||
|
('cmd_list_prefixes', 1, 'Allows listing custom prefixes assigned to users.'),
|
||||||
|
('cmd_remove_prefix', 1, 'Allows removing custom prefixes from users.'),
|
||||||
|
('cmd_prefix_blacklist', 1, 'Allows managing the custom prefix blacklist.')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`max_value` = VALUES(`max_value`),
|
||||||
|
`comment` = VALUES(`comment`);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Explicitly obsolete table from older remember-me attempts.
|
||||||
|
-- The current Java uses users_remember_families only.
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `users_remember_tokens`;
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>com.eu.habbo</groupId>
|
<groupId>com.eu.habbo</groupId>
|
||||||
<artifactId>Habbo</artifactId>
|
<artifactId>Habbo</artifactId>
|
||||||
<version>4.2.8</version>
|
<version>4.2.9</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|||||||
+2
@@ -5,6 +5,7 @@ import com.eu.habbo.messages.PacketManager;
|
|||||||
import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
|
import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
|
||||||
import com.eu.habbo.networking.gameserver.auth.NitroSecureApiHandler;
|
import com.eu.habbo.networking.gameserver.auth.NitroSecureApiHandler;
|
||||||
import com.eu.habbo.networking.gameserver.auth.NitroSecureAssetHandler;
|
import com.eu.habbo.networking.gameserver.auth.NitroSecureAssetHandler;
|
||||||
|
import com.eu.habbo.networking.gameserver.badges.BadgeLeaderboardHttpHandler;
|
||||||
import com.eu.habbo.networking.gameserver.badges.BadgeHttpHandler;
|
import com.eu.habbo.networking.gameserver.badges.BadgeHttpHandler;
|
||||||
import com.eu.habbo.networking.gameserver.codec.WebSocketCodec;
|
import com.eu.habbo.networking.gameserver.codec.WebSocketCodec;
|
||||||
import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
|
import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
|
||||||
@@ -60,6 +61,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
|
|||||||
ch.pipeline().addLast("nitroSecureApiHandler", new NitroSecureApiHandler());
|
ch.pipeline().addLast("nitroSecureApiHandler", new NitroSecureApiHandler());
|
||||||
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
||||||
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
|
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
|
||||||
|
ch.pipeline().addLast("badgeLeaderboardHttpHandler", new BadgeLeaderboardHttpHandler());
|
||||||
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
||||||
ch.pipeline().addLast("wsFrameAggregator", new WebSocketFrameAggregator(MAX_FRAME_SIZE));
|
ch.pipeline().addLast("wsFrameAggregator", new WebSocketFrameAggregator(MAX_FRAME_SIZE));
|
||||||
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
||||||
|
|||||||
+458
@@ -0,0 +1,458 @@
|
|||||||
|
package com.eu.habbo.networking.gameserver.badges;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import com.eu.habbo.networking.gameserver.auth.AccessTokenService;
|
||||||
|
import com.eu.habbo.networking.gameserver.auth.CorsOriginGate;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.channel.ChannelFutureListener;
|
||||||
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
|
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||||
|
import io.netty.handler.codec.http.*;
|
||||||
|
import io.netty.util.ReferenceCountUtil;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public class BadgeLeaderboardHttpHandler extends ChannelInboundHandlerAdapter {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(BadgeLeaderboardHttpHandler.class);
|
||||||
|
|
||||||
|
private static final String BASE_PATH = "/api/badges/leaderboard";
|
||||||
|
private static final long CACHE_TTL_MS = 15_000L;
|
||||||
|
private static final int MAX_BOARD_USERS = 100;
|
||||||
|
|
||||||
|
private static volatile Snapshot cache = null;
|
||||||
|
|
||||||
|
private static final class Snapshot {
|
||||||
|
final List<UserBadgeAggregate> badgeUsers;
|
||||||
|
final List<UserAchievementAggregate> achievementUsers;
|
||||||
|
final JsonArray badgeStats;
|
||||||
|
final long expiresAt;
|
||||||
|
|
||||||
|
Snapshot(List<UserBadgeAggregate> badgeUsers, List<UserAchievementAggregate> achievementUsers, JsonArray badgeStats, long expiresAt) {
|
||||||
|
this.badgeUsers = badgeUsers;
|
||||||
|
this.achievementUsers = achievementUsers;
|
||||||
|
this.badgeStats = badgeStats;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class UserBadgeAggregate {
|
||||||
|
final int userId;
|
||||||
|
final String username;
|
||||||
|
final String figure;
|
||||||
|
final int totalBadges;
|
||||||
|
final EnumMap<Rarity, Integer> counts;
|
||||||
|
|
||||||
|
UserBadgeAggregate(int userId, String username, String figure, int totalBadges, EnumMap<Rarity, Integer> counts) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.figure = figure;
|
||||||
|
this.totalBadges = totalBadges;
|
||||||
|
this.counts = counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class UserAchievementAggregate {
|
||||||
|
final int userId;
|
||||||
|
final String username;
|
||||||
|
final String figure;
|
||||||
|
final int achievementScore;
|
||||||
|
|
||||||
|
UserAchievementAggregate(int userId, String username, String figure, int achievementScore) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.figure = figure;
|
||||||
|
this.achievementScore = achievementScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ViewerProfile {
|
||||||
|
final int userId;
|
||||||
|
final String username;
|
||||||
|
final String figure;
|
||||||
|
|
||||||
|
ViewerProfile(int userId, String username, String figure) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.figure = figure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Rarity {
|
||||||
|
COMMON("common"),
|
||||||
|
RARE("rare"),
|
||||||
|
EPIC("epic"),
|
||||||
|
LEGENDARY("legendary"),
|
||||||
|
MYTHICAL("mythical"),
|
||||||
|
UNIQUE("unique");
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
Rarity(String key) {
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
|
||||||
|
if (!(msg instanceof FullHttpRequest req)) {
|
||||||
|
super.channelRead(ctx, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String path = new QueryStringDecoder(req.uri()).path();
|
||||||
|
if (!path.equals(BASE_PATH) && !path.startsWith(BASE_PATH + "/")) {
|
||||||
|
super.channelRead(ctx, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
handle(ctx, req);
|
||||||
|
} finally {
|
||||||
|
ReferenceCountUtil.release(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||||
|
if (req.method() == HttpMethod.OPTIONS) {
|
||||||
|
sendCors(ctx, req);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, error("Use GET."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Snapshot snapshot = loadSnapshot();
|
||||||
|
int viewerUserId = authenticateOptional(req);
|
||||||
|
ViewerProfile viewerProfile = loadViewerProfile(viewerUserId);
|
||||||
|
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("viewerUserId", viewerUserId);
|
||||||
|
payload.add("badgeStats", cloneArray(snapshot.badgeStats));
|
||||||
|
payload.add("thresholds", buildThresholdsPayload());
|
||||||
|
|
||||||
|
JsonObject boards = new JsonObject();
|
||||||
|
boards.add("totalBadges", buildBadgeBoard(snapshot.badgeUsers, viewerUserId, viewerProfile, null));
|
||||||
|
boards.add("achievementLevel", buildAchievementBoard(snapshot.achievementUsers, viewerUserId, viewerProfile));
|
||||||
|
|
||||||
|
JsonObject rarityBoards = new JsonObject();
|
||||||
|
for (Rarity rarity : Rarity.values()) {
|
||||||
|
rarityBoards.add(rarity.key, buildBadgeBoard(snapshot.badgeUsers, viewerUserId, viewerProfile, rarity));
|
||||||
|
}
|
||||||
|
|
||||||
|
boards.add("rarity", rarityBoards);
|
||||||
|
payload.add("leaderboards", boards);
|
||||||
|
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[badges/leaderboard] unexpected error", e);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, error("Server error."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Snapshot loadSnapshot() throws Exception {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Snapshot current = cache;
|
||||||
|
if (current != null && current.expiresAt >= now) return current;
|
||||||
|
|
||||||
|
synchronized (BadgeLeaderboardHttpHandler.class) {
|
||||||
|
current = cache;
|
||||||
|
if (current != null && current.expiresAt >= now) return current;
|
||||||
|
|
||||||
|
JsonArray badgeStats = new JsonArray();
|
||||||
|
List<UserBadgeAggregate> badgeUsers = new ArrayList<>();
|
||||||
|
List<UserAchievementAggregate> achievementUsers = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||||
|
loadBadgeStats(connection, badgeStats);
|
||||||
|
loadBadgeUsers(connection, badgeUsers);
|
||||||
|
loadAchievementUsers(connection, achievementUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
Snapshot built = new Snapshot(badgeUsers, achievementUsers, badgeStats, now + CACHE_TTL_MS);
|
||||||
|
cache = built;
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadBadgeStats(Connection connection, JsonArray badgeStats) throws Exception {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT badge_code, COUNT(DISTINCT user_id) AS owner_count " +
|
||||||
|
"FROM users_badges GROUP BY badge_code ORDER BY owner_count ASC, badge_code ASC")) {
|
||||||
|
try (ResultSet set = statement.executeQuery()) {
|
||||||
|
while (set.next()) {
|
||||||
|
String badgeCode = set.getString("badge_code");
|
||||||
|
int ownerCount = set.getInt("owner_count");
|
||||||
|
|
||||||
|
JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("badgeCode", badgeCode);
|
||||||
|
entry.addProperty("ownerCount", ownerCount);
|
||||||
|
entry.addProperty("rarity", classify(ownerCount).key);
|
||||||
|
badgeStats.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadBadgeUsers(Connection connection, List<UserBadgeAggregate> badgeUsers) throws Exception {
|
||||||
|
String sql =
|
||||||
|
"SELECT u.id AS user_id, u.username, u.look, " +
|
||||||
|
"COUNT(DISTINCT ub.badge_code) AS total_badges, " +
|
||||||
|
"COUNT(DISTINCT CASE WHEN counts.owner_count > 50 THEN ub.badge_code END) AS common_count, " +
|
||||||
|
"COUNT(DISTINCT CASE WHEN counts.owner_count > 10 AND counts.owner_count <= 50 THEN ub.badge_code END) AS rare_count, " +
|
||||||
|
"COUNT(DISTINCT CASE WHEN counts.owner_count > 6 AND counts.owner_count <= 10 THEN ub.badge_code END) AS epic_count, " +
|
||||||
|
"COUNT(DISTINCT CASE WHEN counts.owner_count > 3 AND counts.owner_count <= 6 THEN ub.badge_code END) AS legendary_count, " +
|
||||||
|
"COUNT(DISTINCT CASE WHEN counts.owner_count > 1 AND counts.owner_count <= 3 THEN ub.badge_code END) AS mythical_count, " +
|
||||||
|
"COUNT(DISTINCT CASE WHEN counts.owner_count = 1 THEN ub.badge_code END) AS unique_count " +
|
||||||
|
"FROM users_badges ub " +
|
||||||
|
"INNER JOIN users u ON u.id = ub.user_id " +
|
||||||
|
"INNER JOIN (SELECT badge_code, COUNT(DISTINCT user_id) AS owner_count FROM users_badges GROUP BY badge_code) counts ON counts.badge_code = ub.badge_code " +
|
||||||
|
"GROUP BY u.id, u.username, u.look";
|
||||||
|
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
try (ResultSet set = statement.executeQuery()) {
|
||||||
|
while (set.next()) {
|
||||||
|
EnumMap<Rarity, Integer> counts = new EnumMap<>(Rarity.class);
|
||||||
|
counts.put(Rarity.COMMON, set.getInt("common_count"));
|
||||||
|
counts.put(Rarity.RARE, set.getInt("rare_count"));
|
||||||
|
counts.put(Rarity.EPIC, set.getInt("epic_count"));
|
||||||
|
counts.put(Rarity.LEGENDARY, set.getInt("legendary_count"));
|
||||||
|
counts.put(Rarity.MYTHICAL, set.getInt("mythical_count"));
|
||||||
|
counts.put(Rarity.UNIQUE, set.getInt("unique_count"));
|
||||||
|
|
||||||
|
badgeUsers.add(new UserBadgeAggregate(
|
||||||
|
set.getInt("user_id"),
|
||||||
|
safe(set.getString("username")),
|
||||||
|
safe(set.getString("look")),
|
||||||
|
set.getInt("total_badges"),
|
||||||
|
counts
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadAchievementUsers(Connection connection, List<UserAchievementAggregate> achievementUsers) throws Exception {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT u.id AS user_id, u.username, u.look, COALESCE(us.achievement_score, 0) AS achievement_score " +
|
||||||
|
"FROM users u INNER JOIN users_settings us ON us.user_id = u.id " +
|
||||||
|
"WHERE COALESCE(us.achievement_score, 0) > 0")) {
|
||||||
|
try (ResultSet set = statement.executeQuery()) {
|
||||||
|
while (set.next()) {
|
||||||
|
achievementUsers.add(new UserAchievementAggregate(
|
||||||
|
set.getInt("user_id"),
|
||||||
|
safe(set.getString("username")),
|
||||||
|
safe(set.getString("look")),
|
||||||
|
set.getInt("achievement_score")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ViewerProfile loadViewerProfile(int viewerUserId) throws Exception {
|
||||||
|
if (viewerUserId <= 0) return null;
|
||||||
|
|
||||||
|
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT id, username, look FROM users WHERE id = ? LIMIT 1")) {
|
||||||
|
statement.setInt(1, viewerUserId);
|
||||||
|
|
||||||
|
try (ResultSet set = statement.executeQuery()) {
|
||||||
|
if (!set.next()) return null;
|
||||||
|
|
||||||
|
return new ViewerProfile(
|
||||||
|
set.getInt("id"),
|
||||||
|
safe(set.getString("username")),
|
||||||
|
safe(set.getString("look"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject buildBadgeBoard(List<UserBadgeAggregate> users, int viewerUserId, ViewerProfile viewerProfile, Rarity rarity) {
|
||||||
|
List<JsonObject> ranked = new ArrayList<>();
|
||||||
|
|
||||||
|
for (UserBadgeAggregate user : users) {
|
||||||
|
int score = (rarity == null) ? user.totalBadges : user.counts.getOrDefault(rarity, 0);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
ranked.add(toEntry(user.userId, user.username, user.figure, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked.sort((a, b) -> {
|
||||||
|
int scoreCompare = Integer.compare(b.get("score").getAsInt(), a.get("score").getAsInt());
|
||||||
|
if (scoreCompare != 0) return scoreCompare;
|
||||||
|
return Integer.compare(a.get("userId").getAsInt(), b.get("userId").getAsInt());
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalizeBoard(ranked, viewerUserId, viewerProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject buildAchievementBoard(List<UserAchievementAggregate> users, int viewerUserId, ViewerProfile viewerProfile) {
|
||||||
|
List<JsonObject> ranked = new ArrayList<>();
|
||||||
|
|
||||||
|
for (UserAchievementAggregate user : users) {
|
||||||
|
if (user.achievementScore <= 0) continue;
|
||||||
|
ranked.add(toEntry(user.userId, user.username, user.figure, user.achievementScore));
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked.sort((a, b) -> {
|
||||||
|
int scoreCompare = Integer.compare(b.get("score").getAsInt(), a.get("score").getAsInt());
|
||||||
|
if (scoreCompare != 0) return scoreCompare;
|
||||||
|
return Integer.compare(a.get("userId").getAsInt(), b.get("userId").getAsInt());
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalizeBoard(ranked, viewerUserId, viewerProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject finalizeBoard(List<JsonObject> ranked, int viewerUserId, ViewerProfile viewerProfile) {
|
||||||
|
JsonArray entries = new JsonArray();
|
||||||
|
JsonObject viewerEntry = null;
|
||||||
|
|
||||||
|
int cappedSize = Math.min(ranked.size(), MAX_BOARD_USERS);
|
||||||
|
|
||||||
|
for (int index = 0; index < cappedSize; index++) {
|
||||||
|
JsonObject entry = ranked.get(index).deepCopy();
|
||||||
|
int rank = index + 1;
|
||||||
|
entry.addProperty("rank", rank);
|
||||||
|
|
||||||
|
entries.add(entry);
|
||||||
|
if (viewerUserId > 0 && entry.get("userId").getAsInt() == viewerUserId) viewerEntry = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewerEntry == null && viewerUserId > 0) {
|
||||||
|
for (int index = 0; index < ranked.size(); index++) {
|
||||||
|
JsonObject entry = ranked.get(index);
|
||||||
|
|
||||||
|
if (entry.get("userId").getAsInt() != viewerUserId) continue;
|
||||||
|
|
||||||
|
viewerEntry = entry.deepCopy();
|
||||||
|
viewerEntry.addProperty("rank", index + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewerEntry == null && viewerProfile != null) {
|
||||||
|
viewerEntry = toEntry(viewerProfile.userId, viewerProfile.username, viewerProfile.figure, 0);
|
||||||
|
viewerEntry.addProperty("rank", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject board = new JsonObject();
|
||||||
|
board.add("entries", entries);
|
||||||
|
board.addProperty("totalPlayers", cappedSize);
|
||||||
|
board.add("viewerEntry", viewerEntry != null ? viewerEntry : new JsonObject());
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject toEntry(int userId, String username, String figure, int score) {
|
||||||
|
JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("userId", userId);
|
||||||
|
entry.addProperty("username", username);
|
||||||
|
entry.addProperty("figure", figure);
|
||||||
|
entry.addProperty("score", score);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject buildThresholdsPayload() {
|
||||||
|
JsonObject thresholds = new JsonObject();
|
||||||
|
thresholds.addProperty("commonMinOwners", 51);
|
||||||
|
thresholds.addProperty("rareMinOwners", 11);
|
||||||
|
thresholds.addProperty("epicMinOwners", 7);
|
||||||
|
thresholds.addProperty("legendaryMinOwners", 4);
|
||||||
|
thresholds.addProperty("mythicalMinOwners", 2);
|
||||||
|
thresholds.addProperty("uniqueOwners", 1);
|
||||||
|
return thresholds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rarity classify(int ownerCount) {
|
||||||
|
if (ownerCount > 50) return Rarity.COMMON;
|
||||||
|
if (ownerCount > 10) return Rarity.RARE;
|
||||||
|
if (ownerCount > 6) return Rarity.EPIC;
|
||||||
|
if (ownerCount > 3) return Rarity.LEGENDARY;
|
||||||
|
if (ownerCount > 1) return Rarity.MYTHICAL;
|
||||||
|
if (ownerCount > 0) return Rarity.UNIQUE;
|
||||||
|
return Rarity.COMMON;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int authenticateOptional(FullHttpRequest req) {
|
||||||
|
String header = req.headers().get(HttpHeaderNames.AUTHORIZATION);
|
||||||
|
if (header == null || header.isEmpty()) return 0;
|
||||||
|
|
||||||
|
String token = header.startsWith("Bearer ") ? header.substring(7).trim() : header.trim();
|
||||||
|
return AccessTokenService.verify(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonArray cloneArray(JsonArray source) {
|
||||||
|
JsonArray copy = new JsonArray();
|
||||||
|
source.forEach(element -> copy.add(element.deepCopy()));
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject error(String message) {
|
||||||
|
JsonObject obj = new JsonObject();
|
||||||
|
obj.addProperty("error", message);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendJson(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, JsonObject body) {
|
||||||
|
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
|
||||||
|
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
|
||||||
|
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
|
||||||
|
response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-store, no-cache, must-revalidate");
|
||||||
|
applyCors(req, response);
|
||||||
|
boolean keepAlive = isKeepAlive(req);
|
||||||
|
if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
|
||||||
|
var future = ctx.writeAndFlush(response);
|
||||||
|
if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||||
|
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
|
||||||
|
applyCors(req, response);
|
||||||
|
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
||||||
|
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||||
|
|
||||||
|
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
|
||||||
|
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||||
|
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||||
|
|
||||||
|
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
|
||||||
|
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
|
||||||
|
} else {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers",
|
||||||
|
"Authorization, Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
|
||||||
|
response.headers().set("Access-Control-Max-Age", "600");
|
||||||
|
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isKeepAlive(FullHttpRequest req) {
|
||||||
|
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
|
||||||
|
return connection == null || !"close".equalsIgnoreCase(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user