diff --git a/Database Updates/012_earnings_center.sql b/Database Updates/012_earnings_center.sql new file mode 100644 index 00000000..7cc8d557 --- /dev/null +++ b/Database Updates/012_earnings_center.sql @@ -0,0 +1,132 @@ +CREATE TABLE IF NOT EXISTS `users_earnings_claims` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `category` varchar(64) NOT NULL, + `period_key` varchar(32) NOT NULL, + `claimed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `users_earnings_claims_unique_period` (`user_id`, `category`, `period_key`), + KEY `users_earnings_claims_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.enabled', '0', 'Enable the emulator-owned earnings center reward hub.'), +('earnings.daily_gift.enabled', '1', 'Enable daily gift earnings row.'), +('earnings.daily_gift.cooldown.seconds', '86400', 'Cooldown in seconds for daily gift earnings claims.'), +('earnings.daily_gift.credits', '0', 'Credits granted by daily gift earnings claims.'), +('earnings.daily_gift.pixels', '0', 'Pixels granted by daily gift earnings claims.'), +('earnings.daily_gift.points', '0', 'Seasonal points granted by daily gift earnings claims.'), +('earnings.daily_gift.points.type', '5', 'Seasonal point type granted by daily gift earnings claims.'), +('earnings.games.enabled', '1', 'Enable games earnings row.'), +('earnings.games.cooldown.seconds', '86400', 'Cooldown in seconds for games earnings claims.'), +('earnings.games.credits', '0', 'Credits granted by games earnings claims.'), +('earnings.games.pixels', '0', 'Pixels granted by games earnings claims.'), +('earnings.games.points', '0', 'Seasonal points granted by games earnings claims.'), +('earnings.games.points.type', '5', 'Seasonal point type granted by games earnings claims.'), +('earnings.achievements.enabled', '1', 'Enable achievements earnings row.'), +('earnings.achievements.cooldown.seconds', '86400', 'Cooldown in seconds for achievements earnings claims.'), +('earnings.achievements.credits', '0', 'Credits granted by achievements earnings claims.'), +('earnings.achievements.pixels', '0', 'Pixels granted by achievements earnings claims.'), +('earnings.achievements.points', '0', 'Seasonal points granted by achievements earnings claims.'), +('earnings.achievements.points.type', '5', 'Seasonal point type granted by achievements earnings claims.'), +('earnings.marketplace.enabled', '1', 'Enable marketplace earnings row.'), +('earnings.marketplace.cooldown.seconds', '86400', 'Cooldown in seconds for marketplace earnings claims.'), +('earnings.marketplace.credits', '0', 'Credits granted by marketplace earnings claims.'), +('earnings.marketplace.pixels', '0', 'Pixels granted by marketplace earnings claims.'), +('earnings.marketplace.points', '0', 'Seasonal points granted by marketplace earnings claims.'), +('earnings.marketplace.points.type', '5', 'Seasonal point type granted by marketplace earnings claims.'), +('earnings.hc_payday.enabled', '1', 'Enable HC payday earnings row.'), +('earnings.hc_payday.cooldown.seconds', '86400', 'Cooldown in seconds for HC payday earnings claims.'), +('earnings.hc_payday.credits', '0', 'Credits granted by HC payday earnings claims.'), +('earnings.hc_payday.pixels', '0', 'Pixels granted by HC payday earnings claims.'), +('earnings.hc_payday.points', '0', 'Seasonal points granted by HC payday earnings claims.'), +('earnings.hc_payday.points.type', '5', 'Seasonal point type granted by HC payday earnings claims.'), +('earnings.level_progress.enabled', '1', 'Enable level progress earnings row.'), +('earnings.level_progress.cooldown.seconds', '86400', 'Cooldown in seconds for level progress earnings claims.'), +('earnings.level_progress.credits', '0', 'Credits granted by level progress earnings claims.'), +('earnings.level_progress.pixels', '0', 'Pixels granted by level progress earnings claims.'), +('earnings.level_progress.points', '0', 'Seasonal points granted by level progress earnings claims.'), +('earnings.level_progress.points.type', '5', 'Seasonal point type granted by level progress earnings claims.'), +('earnings.donations.enabled', '1', 'Enable donations earnings row.'), +('earnings.donations.cooldown.seconds', '86400', 'Cooldown in seconds for donations earnings claims.'), +('earnings.donations.credits', '0', 'Credits granted by donations earnings claims.'), +('earnings.donations.pixels', '0', 'Pixels granted by donations earnings claims.'), +('earnings.donations.points', '0', 'Seasonal points granted by donations earnings claims.'), +('earnings.donations.points.type', '5', 'Seasonal point type granted by donations earnings claims.'), +('earnings.bonus_bag.enabled', '1', 'Enable bonus bag earnings row.'), +('earnings.bonus_bag.cooldown.seconds', '86400', 'Cooldown in seconds for bonus bag earnings claims.'), +('earnings.bonus_bag.credits', '0', 'Credits granted by bonus bag earnings claims.'), +('earnings.bonus_bag.pixels', '0', 'Pixels granted by bonus bag earnings claims.'), +('earnings.bonus_bag.points', '0', 'Seasonal points granted by bonus bag earnings claims.'), +('earnings.bonus_bag.points.type', '5', 'Seasonal point type granted by bonus bag earnings claims.'), +('earnings.mystery_boxes.enabled', '1', 'Enable mystery boxes earnings row.'), +('earnings.mystery_boxes.cooldown.seconds', '86400', 'Cooldown in seconds for mystery boxes earnings claims.'), +('earnings.mystery_boxes.credits', '0', 'Credits granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.pixels', '0', 'Pixels granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.points', '0', 'Seasonal points granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.points.type', '5', 'Seasonal point type granted by mystery boxes earnings claims.'), +('earnings.club_job.enabled', '1', 'Enable club and job earnings row.'), +('earnings.club_job.cooldown.seconds', '86400', 'Cooldown in seconds for club and job earnings claims.'), +('earnings.club_job.credits', '0', 'Credits granted by club and job earnings claims.'), +('earnings.club_job.pixels', '0', 'Pixels granted by club and job earnings claims.'), +('earnings.club_job.points', '0', 'Seasonal points granted by club and job earnings claims.'), +('earnings.club_job.points.type', '5', 'Seasonal point type granted by club and job earnings claims.'); + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.daily_gift.badge', '', 'Badge code granted by daily gift earnings claims.'), +('earnings.daily_gift.item_id', '0', 'Items base id granted by daily gift earnings claims.'), +('earnings.daily_gift.item.quantity', '1', 'Furni quantity granted by daily gift earnings claims.'), +('earnings.daily_gift.hc.days', '0', 'HC days granted by daily gift earnings claims.'), +('earnings.games.badge', '', 'Badge code granted by games earnings claims.'), +('earnings.games.item_id', '0', 'Items base id granted by games earnings claims.'), +('earnings.games.item.quantity', '1', 'Furni quantity granted by games earnings claims.'), +('earnings.games.hc.days', '0', 'HC days granted by games earnings claims.'), +('earnings.achievements.badge', '', 'Badge code granted by achievements earnings claims.'), +('earnings.achievements.item_id', '0', 'Items base id granted by achievements earnings claims.'), +('earnings.achievements.item.quantity', '1', 'Furni quantity granted by achievements earnings claims.'), +('earnings.achievements.hc.days', '0', 'HC days granted by achievements earnings claims.'), +('earnings.marketplace.badge', '', 'Badge code granted by marketplace earnings claims.'), +('earnings.marketplace.item_id', '0', 'Items base id granted by marketplace earnings claims.'), +('earnings.marketplace.item.quantity', '1', 'Furni quantity granted by marketplace earnings claims.'), +('earnings.marketplace.hc.days', '0', 'HC days granted by marketplace earnings claims.'), +('earnings.hc_payday.badge', '', 'Badge code granted by HC payday earnings claims.'), +('earnings.hc_payday.item_id', '0', 'Items base id granted by HC payday earnings claims.'), +('earnings.hc_payday.item.quantity', '1', 'Furni quantity granted by HC payday earnings claims.'), +('earnings.hc_payday.hc.days', '0', 'HC days granted by HC payday earnings claims.'), +('earnings.level_progress.badge', '', 'Badge code granted by level progress earnings claims.'), +('earnings.level_progress.item_id', '0', 'Items base id granted by level progress earnings claims.'), +('earnings.level_progress.item.quantity', '1', 'Furni quantity granted by level progress earnings claims.'), +('earnings.level_progress.hc.days', '0', 'HC days granted by level progress earnings claims.'), +('earnings.donations.badge', '', 'Badge code granted by donations earnings claims.'), +('earnings.donations.item_id', '0', 'Items base id granted by donations earnings claims.'), +('earnings.donations.item.quantity', '1', 'Furni quantity granted by donations earnings claims.'), +('earnings.donations.hc.days', '0', 'HC days granted by donations earnings claims.'), +('earnings.bonus_bag.badge', '', 'Badge code granted by bonus bag earnings claims.'), +('earnings.bonus_bag.item_id', '0', 'Items base id granted by bonus bag earnings claims.'), +('earnings.bonus_bag.item.quantity', '1', 'Furni quantity granted by bonus bag earnings claims.'), +('earnings.bonus_bag.hc.days', '0', 'HC days granted by bonus bag earnings claims.'), +('earnings.mystery_boxes.badge', '', 'Badge code granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.item_id', '0', 'Items base id granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.item.quantity', '1', 'Furni quantity granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.hc.days', '0', 'HC days granted by mystery boxes earnings claims.'), +('earnings.club_job.badge', '', 'Badge code granted by club and job earnings claims.'), +('earnings.club_job.item_id', '0', 'Items base id granted by club and job earnings claims.'), +('earnings.club_job.item.quantity', '1', 'Furni quantity granted by club and job earnings claims.'), +('earnings.club_job.hc.days', '0', 'HC days granted by club and job earnings claims.'); + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.daily_gift.native.enabled', '0', 'Use native hotel subsystem data for daily gift earnings claims when available.'), +('earnings.games.native.enabled', '0', 'Use native hotel subsystem data for games earnings claims when available.'), +('earnings.achievements.native.enabled', '1', 'Use achievement score thresholds for achievements earnings claims.'), +('earnings.marketplace.native.enabled', '1', 'Use marketplace sold item payouts for marketplace earnings claims.'), +('earnings.hc_payday.native.enabled', '1', 'Use unclaimed HC payday logs for HC payday earnings claims.'), +('earnings.level_progress.native.enabled', '1', 'Use talent track levels for level progress earnings claims.'), +('earnings.donations.native.enabled', '0', 'Use native hotel subsystem data for donations earnings claims when available.'), +('earnings.bonus_bag.native.enabled', '0', 'Use native hotel subsystem data for bonus bag earnings claims when available.'), +('earnings.mystery_boxes.native.enabled', '0', 'Use native hotel subsystem data for mystery boxes earnings claims when available.'), +('earnings.club_job.native.enabled', '0', 'Use native hotel subsystem data for club and job earnings claims when available.'); + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.achievements.min_score', '1', 'Minimum achievement score required before achievements earnings can be claimed.'), +('earnings.achievements.score.step', '100', 'Achievement score bucket size used to prevent repeated claims for the same progress band.'), +('earnings.level_progress.min_level', '1', 'Minimum citizenship/helper talent level required before level progress earnings can be claimed.'); diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 833cfd5f..52c7f8b1 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -166,6 +166,7 @@ public final class Emulator { Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_give_rank"); Emulator.config.register("rcon.execute_command.allowed_permissions", ""); + registerEarningsSettings(); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(startupCard(hotelTimezoneId)); Emulator.texts.register("camera.permission", "You don't have permission to use the camera!"); @@ -488,6 +489,42 @@ public final class Emulator { return gameServer; } + private static void registerEarningsSettings() { + Emulator.config.register("earnings.enabled", "0"); + + String[] categories = { + "daily_gift", + "games", + "achievements", + "marketplace", + "hc_payday", + "level_progress", + "donations", + "bonus_bag", + "mystery_boxes", + "club_job" + }; + + for (String category : categories) { + String prefix = "earnings." + category + "."; + Emulator.config.register(prefix + "enabled", "1"); + Emulator.config.register(prefix + "cooldown.seconds", "86400"); + Emulator.config.register(prefix + "credits", "0"); + Emulator.config.register(prefix + "pixels", "0"); + Emulator.config.register(prefix + "points", "0"); + Emulator.config.register(prefix + "points.type", "5"); + Emulator.config.register(prefix + "badge", ""); + Emulator.config.register(prefix + "item_id", "0"); + Emulator.config.register(prefix + "item.quantity", "1"); + Emulator.config.register(prefix + "hc.days", "0"); + Emulator.config.register(prefix + "native.enabled", (category.equals("marketplace") || category.equals("hc_payday") || category.equals("achievements") || category.equals("level_progress")) ? "1" : "0"); + } + + Emulator.config.register("earnings.achievements.min_score", "1"); + Emulator.config.register("earnings.achievements.score.step", "100"); + Emulator.config.register("earnings.level_progress.min_level", "1"); + } + public static RCONServer getRconServer() { return rconServer; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java new file mode 100644 index 00000000..547df6c5 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java @@ -0,0 +1,38 @@ +package com.eu.habbo.habbohotel.earnings; + +import java.util.Arrays; +import java.util.Optional; + +public enum EarningsCategory { + DAILY_GIFT("daily_gift"), + GAMES("games"), + ACHIEVEMENTS("achievements"), + MARKETPLACE("marketplace"), + HC_PAYDAY("hc_payday"), + LEVEL_PROGRESS("level_progress"), + DONATIONS("donations"), + BONUS_BAG("bonus_bag"), + MYSTERY_BOXES("mystery_boxes"), + CLUB_JOB("club_job"); + + private final String key; + + EarningsCategory(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + public static Optional fromKey(String key) { + if (key == null || key.isBlank()) { + return Optional.empty(); + } + + String normalized = key.trim().toLowerCase(); + return Arrays.stream(values()) + .filter(category -> category.key.equals(normalized)) + .findFirst(); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java new file mode 100644 index 00000000..88ed4ebc --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java @@ -0,0 +1,570 @@ +package com.eu.habbo.habbohotel.earnings; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.catalog.marketplace.MarketPlace; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboBadge; +import com.eu.habbo.habbohotel.users.subscriptions.SubscriptionHabboClub; +import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class EarningsCenterManager { + public static final String CONFIG_PREFIX = "earnings."; + private static final int DEFAULT_COOLDOWN_SECONDS = 86400; + private static final int DEFAULT_POINTS_TYPE = 5; + private static final int MAX_CONFIGURED_REWARD = 1_000_000; + private static final int MAX_ITEM_QUANTITY = 100; + private static final int MAX_HC_DAYS = 365; + + private final ConfigSource config; + private final ClaimRepository claims; + private final RewardApplier rewards; + private final NativeIntegration nativeIntegration; + private final Clock clock; + + public EarningsCenterManager() { + this(new EmulatorConfigSource(), new JdbcClaimRepository(), new HabboRewardApplier(), new DefaultNativeIntegration(), Clock.systemUTC()); + } + + public EarningsCenterManager(ConfigSource config, ClaimRepository claims, RewardApplier rewards, Clock clock) { + this(config, claims, rewards, new NoopNativeIntegration(), clock); + } + + public EarningsCenterManager(ConfigSource config, ClaimRepository claims, RewardApplier rewards, NativeIntegration nativeIntegration, Clock clock) { + this.config = config; + this.claims = claims; + this.rewards = rewards; + this.nativeIntegration = nativeIntegration; + this.clock = clock; + } + + public List getEntries(Habbo habbo) { + int userId = getUserId(habbo); + int now = now(); + List entries = new ArrayList<>(); + + for (EarningsCategory category : EarningsCategory.values()) { + entries.add(buildEntry(habbo, userId, category, now)); + } + + return entries; + } + + public EarningsClaimResult claim(Habbo habbo, String categoryKey) { + Optional requestedCategory = EarningsCategory.fromKey(categoryKey); + if (requestedCategory.isEmpty()) { + return new EarningsClaimResult(null, EarningsClaimResult.Status.UNKNOWN_CATEGORY, null); + } + + return claim(habbo, requestedCategory.get()); + } + + public List claimAll(Habbo habbo) { + List results = new ArrayList<>(); + + for (EarningsCategory category : EarningsCategory.values()) { + results.add(claim(habbo, category)); + } + + return results; + } + + private EarningsClaimResult claim(Habbo habbo, EarningsCategory category) { + int userId = getUserId(habbo); + int now = now(); + CategoryDefinition definition = loadDefinition(habbo, category); + + if (!definition.enabled()) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.DISABLED, buildEntry(habbo, userId, category, now)); + } + + if (this.nativeIntegration.handles(category) && nativeEnabled(category)) { + return claimNative(habbo, userId, category, now, definition); + } + + if (definition.rewards().isEmpty()) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now)); + } + + if (!isEligibleForProgressClaim(habbo, category)) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now)); + } + + String periodKey = periodKey(habbo, category, now, definition.cooldownSeconds()); + + try { + if (!this.claims.recordClaim(userId, category.getKey(), periodKey, now)) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.ALREADY_CLAIMED, buildEntry(habbo, userId, category, now)); + } + + this.rewards.grant(habbo, definition.rewards()); + return new EarningsClaimResult(category, EarningsClaimResult.Status.SUCCESS, buildEntry(habbo, userId, category, now)); + } catch (SQLException e) { + try { + this.claims.removeClaim(userId, category.getKey(), periodKey); + } catch (SQLException ignored) { + } + return new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now)); + } + } + + private EarningsClaimResult claimNative(Habbo habbo, int userId, EarningsCategory category, int now, CategoryDefinition definition) { + try { + if (definition.rewards().isEmpty() || !this.nativeIntegration.hasClaim(habbo, category)) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now)); + } + + return this.nativeIntegration.claim(habbo, category) + ? new EarningsClaimResult(category, EarningsClaimResult.Status.SUCCESS, buildEntry(habbo, userId, category, now)) + : new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now)); + } catch (SQLException e) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now)); + } + } + + private EarningsEntry buildEntry(Habbo habbo, int userId, EarningsCategory category, int now) { + CategoryDefinition definition = loadDefinition(habbo, category); + boolean claimable = false; + int nextClaimAt = 0; + + if (definition.enabled() && !definition.rewards().isEmpty()) { + if (this.nativeIntegration.handles(category) && nativeEnabled(category)) { + try { + claimable = this.nativeIntegration.hasClaim(habbo, category); + } catch (SQLException e) { + claimable = false; + } + + return new EarningsEntry(category, true, claimable, 0, definition.rewards()); + } + + if (!isEligibleForProgressClaim(habbo, category)) { + return new EarningsEntry(category, true, false, 0, definition.rewards()); + } + + String periodKey = periodKey(habbo, category, now, definition.cooldownSeconds()); + + try { + claimable = !this.claims.hasClaim(userId, category.getKey(), periodKey); + nextClaimAt = claimable ? 0 : nextPeriodStart(now, definition.cooldownSeconds()); + } catch (SQLException e) { + claimable = false; + nextClaimAt = nextPeriodStart(now, definition.cooldownSeconds()); + } + } + + return new EarningsEntry(category, definition.enabled(), claimable, nextClaimAt, definition.rewards()); + } + + private CategoryDefinition loadDefinition(Habbo habbo, EarningsCategory category) { + String key = CONFIG_PREFIX + category.getKey() + "."; + boolean enabled = this.config.getBoolean(CONFIG_PREFIX + "enabled", false) + && this.config.getBoolean(key + "enabled", true); + int cooldown = Math.max(60, this.config.getInt(key + "cooldown.seconds", DEFAULT_COOLDOWN_SECONDS)); + int pointsType = Math.max(0, this.config.getInt(key + "points.type", DEFAULT_POINTS_TYPE)); + List rewards = new ArrayList<>(); + + if (nativeEnabled(category) && this.nativeIntegration.handles(category)) { + try { + rewards.addAll(this.nativeIntegration.rewards(habbo, category)); + } catch (SQLException ignored) { + } + } else { + addReward(rewards, EarningsReward.TYPE_CREDITS, this.config.getInt(key + "credits", 0), 0); + addReward(rewards, EarningsReward.TYPE_PIXELS, this.config.getInt(key + "pixels", 0), 0); + addReward(rewards, EarningsReward.TYPE_POINTS, this.config.getInt(key + "points", 0), pointsType); + addBadgeReward(rewards, this.config.getValue(key + "badge", "")); + addItemReward(rewards, this.config.getInt(key + "item_id", 0), this.config.getInt(key + "item.quantity", 1)); + addHcReward(rewards, this.config.getInt(key + "hc.days", 0)); + } + + return new CategoryDefinition(enabled, cooldown, rewards); + } + + private boolean nativeEnabled(EarningsCategory category) { + return this.config.getBoolean(CONFIG_PREFIX + category.getKey() + ".native.enabled", true); + } + + private boolean isEligibleForProgressClaim(Habbo habbo, EarningsCategory category) { + if (!nativeEnabled(category) || habbo == null || habbo.getHabboStats() == null) { + return true; + } + + if (category == EarningsCategory.ACHIEVEMENTS) { + int minimumScore = Math.max(1, this.config.getInt(CONFIG_PREFIX + category.getKey() + ".min_score", 1)); + return habbo.getHabboStats().getAchievementScore() >= minimumScore; + } + + if (category == EarningsCategory.LEVEL_PROGRESS) { + int minimumLevel = Math.max(0, this.config.getInt(CONFIG_PREFIX + category.getKey() + ".min_level", 1)); + int highestLevel = Math.max(habbo.getHabboStats().citizenshipLevel, habbo.getHabboStats().helpersLevel); + return highestLevel >= minimumLevel; + } + + return true; + } + + private void addReward(List rewards, String type, int amount, int pointsType) { + int clampedAmount = Math.min(Math.max(0, amount), MAX_CONFIGURED_REWARD); + if (clampedAmount > 0) { + rewards.add(new EarningsReward(type, clampedAmount, pointsType)); + } + } + + private void addBadgeReward(List rewards, String badgeCode) { + if (badgeCode == null || !badgeCode.matches("[A-Za-z0-9_\\-]{1,64}")) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_BADGE, 1, 0, badgeCode)); + } + + private void addItemReward(List rewards, int itemId, int quantity) { + if (itemId <= 0 || quantity <= 0) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_ITEM, Math.min(quantity, MAX_ITEM_QUANTITY), 0, String.valueOf(itemId))); + } + + private void addHcReward(List rewards, int days) { + if (days <= 0) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_HC_DAYS, Math.min(days, MAX_HC_DAYS), 0)); + } + + private int getUserId(Habbo habbo) { + if (habbo == null || habbo.getHabboInfo() == null) { + return 0; + } + + return habbo.getHabboInfo().getId(); + } + + private int now() { + return (int) (this.clock.instant().getEpochSecond()); + } + + private String periodKey(Habbo habbo, EarningsCategory category, int now, int cooldownSeconds) { + if (nativeEnabled(category) && habbo != null && habbo.getHabboStats() != null) { + if (category == EarningsCategory.ACHIEVEMENTS) { + int scoreStep = Math.max(1, this.config.getInt(CONFIG_PREFIX + category.getKey() + ".score.step", 100)); + return "score:" + (habbo.getHabboStats().getAchievementScore() / scoreStep); + } + + if (category == EarningsCategory.LEVEL_PROGRESS) { + int highestLevel = Math.max(habbo.getHabboStats().citizenshipLevel, habbo.getHabboStats().helpersLevel); + return "level:" + highestLevel; + } + } + + return String.valueOf(now / cooldownSeconds); + } + + private int nextPeriodStart(int now, int cooldownSeconds) { + return ((now / cooldownSeconds) + 1) * cooldownSeconds; + } + + private record CategoryDefinition(boolean enabled, int cooldownSeconds, List rewards) { + } + + public interface ConfigSource { + boolean getBoolean(String key, boolean defaultValue); + + int getInt(String key, int defaultValue); + + String getValue(String key, String defaultValue); + } + + public interface ClaimRepository { + boolean hasClaim(int userId, String category, String periodKey) throws SQLException; + + boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException; + + void removeClaim(int userId, String category, String periodKey) throws SQLException; + } + + public interface RewardApplier { + void grant(Habbo habbo, List rewards) throws SQLException; + } + + public interface NativeIntegration { + boolean handles(EarningsCategory category); + + boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException; + + List rewards(Habbo habbo, EarningsCategory category) throws SQLException; + + boolean claim(Habbo habbo, EarningsCategory category) throws SQLException; + } + + private static class EmulatorConfigSource implements ConfigSource { + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return Emulator.getConfig().getBoolean(key, defaultValue); + } + + @Override + public int getInt(String key, int defaultValue) { + return Emulator.getConfig().getInt(key, defaultValue); + } + + @Override + public String getValue(String key, String defaultValue) { + return Emulator.getConfig().getValue(key, defaultValue); + } + } + + private static class JdbcClaimRepository implements ClaimRepository { + @Override + public boolean hasClaim(int userId, String category, String periodKey) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT 1 FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + return statement.executeQuery().next(); + } + } + + @Override + public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("INSERT INTO users_earnings_claims (user_id, category, period_key, claimed_at) VALUES (?, ?, ?, FROM_UNIXTIME(?))")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + statement.setInt(4, claimedAt); + return statement.executeUpdate() == 1; + } catch (SQLIntegrityConstraintViolationException duplicate) { + return false; + } + } + + @Override + public void removeClaim(int userId, String category, String periodKey) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + statement.executeUpdate(); + } + } + } + + private static class HabboRewardApplier implements RewardApplier { + @Override + public void grant(Habbo habbo, List rewards) throws SQLException { + if (habbo == null) { + return; + } + + for (EarningsReward reward : rewards) { + switch (reward.getType()) { + case EarningsReward.TYPE_CREDITS -> habbo.giveCredits(reward.getAmount()); + case EarningsReward.TYPE_PIXELS -> habbo.givePixels(reward.getAmount()); + case EarningsReward.TYPE_POINTS -> habbo.givePoints(reward.getPointsType(), reward.getAmount()); + case EarningsReward.TYPE_BADGE -> grantBadge(habbo, reward.getData()); + case EarningsReward.TYPE_ITEM -> grantItem(habbo, Integer.parseInt(reward.getData()), reward.getAmount()); + case EarningsReward.TYPE_HC_DAYS -> grantHcDays(habbo, reward.getAmount()); + default -> { + } + } + } + } + + private void grantBadge(Habbo habbo, String badgeCode) throws SQLException { + if (habbo.getInventory().getBadgesComponent().hasBadge(badgeCode)) { + return; + } + + HabboBadge badge = new HabboBadge(0, badgeCode, 0, habbo); + badge.run(); + habbo.getInventory().getBadgesComponent().addBadge(badge); + if (habbo.getClient() != null) { + habbo.getClient().sendResponse(new AddUserBadgeComposer(badge)); + } + } + + private void grantItem(Habbo habbo, int itemId, int quantity) throws SQLException { + if (!itemExists(itemId)) { + throw new SQLException("Unknown earnings item reward " + itemId); + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("INSERT INTO items (user_id, item_id, extra_data) VALUES (?, ?, '')")) { + for (int i = 0; i < quantity; i++) { + statement.setInt(1, habbo.getHabboInfo().getId()); + statement.setInt(2, itemId); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private boolean itemExists(int itemId) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT id FROM items_base WHERE id = ? LIMIT 1")) { + statement.setInt(1, itemId); + try (ResultSet set = statement.executeQuery()) { + return set.next(); + } + } + } + + private void grantHcDays(Habbo habbo, int days) throws SQLException { + int now = Emulator.getIntUnixTimestamp(); + int current = habbo.getHabboStats().getClubExpireTimestamp(); + int newExpire = (current > now ? current : now) + (days * 86400); + + habbo.getHabboStats().setClubExpireTimestamp(newExpire); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET club_expire_timestamp = ? WHERE user_id = ? LIMIT 1")) { + statement.setInt(1, newExpire); + statement.setInt(2, habbo.getHabboInfo().getId()); + statement.executeUpdate(); + } + } + } + + private static class NoopNativeIntegration implements NativeIntegration { + @Override + public boolean handles(EarningsCategory category) { + return false; + } + + @Override + public boolean hasClaim(Habbo habbo, EarningsCategory category) { + return false; + } + + @Override + public List rewards(Habbo habbo, EarningsCategory category) { + return List.of(); + } + + @Override + public boolean claim(Habbo habbo, EarningsCategory category) { + return false; + } + } + + private static class DefaultNativeIntegration implements NativeIntegration { + @Override + public boolean handles(EarningsCategory category) { + return category == EarningsCategory.MARKETPLACE || category == EarningsCategory.HC_PAYDAY; + } + + @Override + public boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException { + return !rewards(habbo, category).isEmpty(); + } + + @Override + public List rewards(Habbo habbo, EarningsCategory category) throws SQLException { + if (habbo == null) { + return List.of(); + } + + if (category == EarningsCategory.MARKETPLACE) { + int soldPriceTotal = habbo.getInventory().getSoldPriceTotal(); + if (soldPriceTotal <= 0) { + return List.of(); + } + + if (MarketPlace.MARKETPLACE_CURRENCY == 0) { + return List.of(new EarningsReward(EarningsReward.TYPE_CREDITS, soldPriceTotal, 0)); + } + + return List.of(new EarningsReward(EarningsReward.TYPE_POINTS, soldPriceTotal, MarketPlace.MARKETPLACE_CURRENCY)); + } + + if (category == EarningsCategory.HC_PAYDAY) { + return hcPaydayRewards(habbo); + } + + return List.of(); + } + + @Override + public boolean claim(Habbo habbo, EarningsCategory category) throws SQLException { + if (habbo == null || habbo.getClient() == null) { + return false; + } + + if (category == EarningsCategory.MARKETPLACE) { + if (habbo.getInventory().getSoldPriceTotal() <= 0) { + return false; + } + + MarketPlace.getCredits(habbo.getClient()); + return true; + } + + if (category == EarningsCategory.HC_PAYDAY) { + if (hcPaydayRewards(habbo).isEmpty()) { + return false; + } + + SubscriptionHabboClub.processUnclaimed(habbo); + return true; + } + + return false; + } + + private List hcPaydayRewards(Habbo habbo) throws SQLException { + List rewards = new ArrayList<>(); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT currency, SUM(total_payout) AS amount FROM logs_hc_payday WHERE user_id = ? AND claimed = 0 GROUP BY currency")) { + statement.setInt(1, habbo.getHabboInfo().getId()); + + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + EarningsReward reward = currencyReward(set.getString("currency"), set.getInt("amount")); + if (reward != null) { + rewards.add(reward); + } + } + } + } + + return rewards; + } + + private EarningsReward currencyReward(String currency, int amount) { + if (amount <= 0) { + return null; + } + + String normalized = currency == null ? "" : currency.trim().toLowerCase(); + return switch (normalized) { + case "credits", "credit", "coins", "coin" -> new EarningsReward(EarningsReward.TYPE_CREDITS, amount, 0); + case "duckets", "ducket", "pixels", "pixel" -> new EarningsReward(EarningsReward.TYPE_PIXELS, amount, 0); + case "diamonds", "diamond" -> new EarningsReward(EarningsReward.TYPE_POINTS, amount, 5); + default -> { + try { + yield new EarningsReward(EarningsReward.TYPE_POINTS, amount, Math.max(0, Integer.parseInt(normalized))); + } catch (NumberFormatException e) { + yield null; + } + } + }; + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java new file mode 100644 index 00000000..ab5dc896 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java @@ -0,0 +1,42 @@ +package com.eu.habbo.habbohotel.earnings; + +public class EarningsClaimResult { + public enum Status { + SUCCESS, + DISABLED, + UNKNOWN_CATEGORY, + ALREADY_CLAIMED, + NO_REWARD, + ERROR + } + + private final EarningsCategory category; + private final Status status; + private final EarningsEntry entry; + + public EarningsClaimResult(EarningsCategory category, Status status, EarningsEntry entry) { + this.category = category; + this.status = status; + this.entry = entry; + } + + public EarningsCategory getCategory() { + return category; + } + + public String getCategoryKey() { + return category == null ? "" : category.getKey(); + } + + public Status getStatus() { + return status; + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public EarningsEntry getEntry() { + return entry; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java new file mode 100644 index 00000000..2fdf2318 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java @@ -0,0 +1,39 @@ +package com.eu.habbo.habbohotel.earnings; + +import java.util.List; + +public class EarningsEntry { + private final EarningsCategory category; + private final boolean enabled; + private final boolean claimable; + private final int nextClaimAt; + private final List rewards; + + public EarningsEntry(EarningsCategory category, boolean enabled, boolean claimable, int nextClaimAt, List rewards) { + this.category = category; + this.enabled = enabled; + this.claimable = claimable; + this.nextClaimAt = Math.max(0, nextClaimAt); + this.rewards = List.copyOf(rewards); + } + + public EarningsCategory getCategory() { + return category; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isClaimable() { + return claimable; + } + + public int getNextClaimAt() { + return nextClaimAt; + } + + public List getRewards() { + return rewards; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java new file mode 100644 index 00000000..9531c6a7 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java @@ -0,0 +1,42 @@ +package com.eu.habbo.habbohotel.earnings; + +public class EarningsReward { + public static final String TYPE_CREDITS = "credits"; + public static final String TYPE_PIXELS = "pixels"; + public static final String TYPE_POINTS = "points"; + public static final String TYPE_BADGE = "badge"; + public static final String TYPE_ITEM = "item"; + public static final String TYPE_HC_DAYS = "hc_days"; + + private final String type; + private final int amount; + private final int pointsType; + private final String data; + + public EarningsReward(String type, int amount, int pointsType) { + this(type, amount, pointsType, ""); + } + + public EarningsReward(String type, int amount, int pointsType, String data) { + this.type = type; + this.amount = Math.max(0, amount); + this.pointsType = Math.max(0, pointsType); + this.data = data == null ? "" : data; + } + + public String getType() { + return type; + } + + public int getAmount() { + return amount; + } + + public int getPointsType() { + return pointsType; + } + + public String getData() { + return data; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java index 9883ec7a..efa38b8b 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java @@ -3,6 +3,7 @@ package com.eu.habbo.habbohotel.modtool; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.gameclients.GameClient; import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.permissions.Rank; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.rooms.RoomState; import com.eu.habbo.habbohotel.users.Habbo; @@ -423,12 +424,16 @@ public class ModToolManager { } public void kick(Habbo moderator, Habbo target, String message) { - if (moderator.hasPermission(Permission.ACC_SUPPORTTOOL) && !target.hasPermission(Permission.ACC_UNKICKABLE)) { - if (target.getHabboInfo().getCurrentRoom() != null) { - Emulator.getGameEnvironment().getRoomManager().leaveRoom(target, target.getHabboInfo().getCurrentRoom()); - } - this.alert(moderator, target, message, SupportUserAlertedReason.KICKED); + if (moderator == null || target == null || !moderator.hasPermission(Permission.ACC_SUPPORTTOOL) || + target.hasPermission(Permission.ACC_UNKICKABLE) || + !canModerateTarget(moderator, target.getHabboInfo().getId())) { + return; } + + if (target.getHabboInfo().getCurrentRoom() != null) { + Emulator.getGameEnvironment().getRoomManager().leaveRoom(target, target.getHabboInfo().getCurrentRoom()); + } + this.alert(moderator, target, message, SupportUserAlertedReason.KICKED); } public List ban(int targetUserId, Habbo moderator, String reason, int duration, ModToolBanType type, int cfhTopic) { @@ -443,7 +448,7 @@ public class ModToolManager { return bans; } - if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) { + if (!canModerateTarget(moderator, targetUserId)) { return bans; } @@ -468,7 +473,7 @@ public class ModToolManager { if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) { for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(ban.ip)) { - if (h.getHabboInfo().getRank().getId() >= moderator.getHabboInfo().getRank().getId()) continue; + if (!canModerateTarget(moderator, h.getHabboInfo().getId())) continue; ban = new ModToolBan(h.getHabboInfo().getId(), h != null ? h.getHabboInfo().getIpLogin() : "offline", h != null ? h.getClient().getMachineId() : "offline", moderator.getHabboInfo().getId(), Emulator.getIntUnixTimestamp() + duration, reason, type, cfhTopic); Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); @@ -480,7 +485,7 @@ public class ModToolManager { if ((type == ModToolBanType.MACHINE || type == ModToolBanType.SUPER) && target != null && !ban.machineId.equals("offline")) { for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithMachineId(ban.machineId)) { - if (h.getHabboInfo().getRank().getId() >= moderator.getHabboInfo().getRank().getId()) continue; + if (!canModerateTarget(moderator, h.getHabboInfo().getId())) continue; ban = new ModToolBan(h.getHabboInfo().getId(), h != null ? h.getHabboInfo().getIpLogin() : "offline", h != null ? h.getClient().getMachineId() : "offline", moderator.getHabboInfo().getId(), Emulator.getIntUnixTimestamp() + duration, reason, type, cfhTopic); Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); @@ -501,10 +506,27 @@ public class ModToolManager { if (targetInfo == null) return false; - return targetInfo.getRank().getId() < moderator.getHabboInfo().getRank().getId(); + int moderatorRankId = moderator.getHabboInfo().getRank().getId(); + int targetRankId = targetInfo.getRank().getId(); + + return targetRankId < moderatorRankId || isCoreRank(moderatorRankId) && targetRankId <= moderatorRankId; + } + + private static boolean isCoreRank(int rankId) { + int highestRankId = 0; + for (Rank rank : Emulator.getGameEnvironment().getPermissionsManager().getAllRanks()) { + highestRankId = Math.max(highestRankId, rank.getId()); + } + + return highestRankId > 0 && rankId >= highestRankId; } public void roomAction(Room room, Habbo moderator, boolean kickUsers, boolean lockDoor, boolean changeTitle) { + if (room == null || moderator == null || !moderator.hasPermission(Permission.ACC_SUPPORTTOOL) || + !canModerateTarget(moderator, room.getOwnerId())) { + return; + } + SupportRoomActionEvent roomActionEvent = new SupportRoomActionEvent(moderator, room, kickUsers, lockDoor, changeTitle); Emulator.getPluginManager().fireEvent(roomActionEvent); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java index 14b2dfeb..85275631 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java @@ -128,6 +128,10 @@ public class ModToolSanctions { } public void run(int habboId, Habbo self, int sanctionLevel, int cfhTopic, String reason, int tradeLockedUntil, boolean isMuted, int muteDuration) { + if (!ModToolManager.canModerateTarget(self, habboId)) { + return; + } + sanctionLevel++; ModToolSanctionLevelItem sanctionLevelItem = getSanctionLevelItem(sanctionLevel); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index 9f372c5b..7bf5460b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -21,6 +21,7 @@ import com.eu.habbo.messages.incoming.catalog.recycler.RequestRecyclerLogicEvent import com.eu.habbo.messages.incoming.crafting.*; import com.eu.habbo.messages.incoming.events.calendar.AdventCalendarForceOpenEvent; import com.eu.habbo.messages.incoming.events.calendar.AdventCalendarOpenDayEvent; +import com.eu.habbo.messages.incoming.earnings.*; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorRequestBlockedTilesEvent; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorRequestDoorSettingsEvent; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorSaveEvent; @@ -130,6 +131,7 @@ public class PacketManager { this.registerCrafting(); this.registerCamera(); this.registerGameCenter(); + this.registerEarnings(); } public PacketNames getNames() { @@ -766,4 +768,10 @@ public class PacketManager { this.registerHandler(Incoming.SoundboardPlayEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardPlayEvent.class); this.registerHandler(Incoming.SoundboardSetEnabledEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardSetEnabledEvent.class); } + + void registerEarnings() throws Exception { + this.registerHandler(Incoming.RequestEarningsCenterEvent, RequestEarningsCenterEvent.class); + this.registerHandler(Incoming.ClaimEarningsRewardEvent, ClaimEarningsRewardEvent.class); + this.registerHandler(Incoming.ClaimAllEarningsRewardsEvent, ClaimAllEarningsRewardsEvent.class); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 3a0bdf1a..cd8ab2c6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -501,6 +501,9 @@ public class Incoming { public static final int WheelAdminSavePrizesEvent = 9305; public static final int SoundboardPlayEvent = 9306; public static final int SoundboardSetEnabledEvent = 9307; + public static final int RequestEarningsCenterEvent = 9308; + public static final int ClaimEarningsRewardEvent = 9309; + public static final int ClaimAllEarningsRewardsEvent = 9310; public static final int RequestMentionsEvent = 4803; public static final int MarkMentionsReadEvent = 4804; public static final int DeleteMentionEvent = 4805; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java new file mode 100644 index 00000000..c83d0d69 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java @@ -0,0 +1,18 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsClaimResultComposer; + +public class ClaimAllEarningsRewardsEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 3000; + } + + @Override + public void handle() { + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsClaimResultComposer(manager.claimAll(this.client.getHabbo()))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java new file mode 100644 index 00000000..f11d8d86 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java @@ -0,0 +1,19 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsClaimResultComposer; + +public class ClaimEarningsRewardEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1000; + } + + @Override + public void handle() { + String categoryKey = this.packet.readString(); + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsClaimResultComposer(manager.claim(this.client.getHabbo(), categoryKey))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java new file mode 100644 index 00000000..b138a96e --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java @@ -0,0 +1,18 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsCenterComposer; + +public class RequestEarningsCenterEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 500; + } + + @Override + public void handle() { + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsCenterComposer(manager.getEntries(this.client.getHabbo()))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java new file mode 100644 index 00000000..36f8876f --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +final class GuildForumInputGuard { + static final int MAX_PAGE_LIMIT = 50; + static final int MAX_MARK_READ_BATCH = 50; + + private GuildForumInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isPositiveId(int id) { + return id > 0; + } + + static boolean isValidPage(int index, int limit) { + return index >= 0 && limit > 0 && limit <= MAX_PAGE_LIMIT; + } + + static boolean isValidMarkReadBatch(int count) { + return count > 0 && count <= MAX_MARK_READ_BATCH; + } + + static boolean isSettingsState(int state) { + return state >= 0 && state <= 3; + } + + static boolean isThreadModerationState(int state) { + return state == 1 || state == 10 || state == 20; + } + + static boolean isMessageModerationState(int state) { + return state == 1 || state == 10 || state == 20; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java index ace83232..fa83390b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java @@ -24,11 +24,19 @@ public class GuildForumMarkAsReadEvent extends MessageHandler { int userId = this.client.getHabbo().getHabboInfo().getId(); int timestamp = Emulator.getIntUnixTimestamp(); + if (!GuildForumInputGuard.isValidMarkReadBatch(count)) { + return; + } + for (int i = 0; i < count; i++) { int guildId = this.packet.readInt(); this.packet.readInt(); // messageId (not used, we track by timestamp) this.packet.readBoolean(); // isRead + if (!GuildForumInputGuard.isPositiveId(guildId)) { + continue; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "INSERT INTO `guild_forum_views` (`user_id`, `guild_id`, `timestamp`) VALUES (?, ?, ?) " + "ON DUPLICATE KEY UPDATE `timestamp` = ?" diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java index 51b9ec0b..5a87f77c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java @@ -28,6 +28,14 @@ public class GuildForumModerateMessageEvent extends MessageHandler { int messageId = packet.readInt(); int state = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isPositiveId(threadId) || + !GuildForumInputGuard.isPositiveId(messageId) || + !GuildForumInputGuard.isMessageModerationState(state)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -85,4 +93,4 @@ public class GuildForumModerateMessageEvent extends MessageHandler { } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java index 3fa8905b..65e3442a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java @@ -36,6 +36,13 @@ public class GuildForumModerateThreadEvent extends MessageHandler { int threadId = packet.readInt(); int state = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isPositiveId(threadId) || + !GuildForumInputGuard.isThreadModerationState(state)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -108,4 +115,4 @@ public class GuildForumModerateThreadEvent extends MessageHandler { LOGGER.error("Failed to delete thread " + threadId, e); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java index 5ca0d1b4..465360a9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java @@ -25,8 +25,13 @@ public class GuildForumPostThreadEvent extends MessageHandler { public void handle() throws Exception { int guildId = this.packet.readInt(); int threadId = this.packet.readInt(); - String subject = Emulator.getGameEnvironment().getWordFilter().filter(this.packet.readString(), this.client.getHabbo()); - String message = Emulator.getGameEnvironment().getWordFilter().filter(this.packet.readString(), this.client.getHabbo()); + String subject = Emulator.getGameEnvironment().getWordFilter().filter(GuildForumInputGuard.normalize(this.packet.readString()), this.client.getHabbo()); + String message = Emulator.getGameEnvironment().getWordFilter().filter(GuildForumInputGuard.normalize(this.packet.readString()), this.client.getHabbo()); + + if (!GuildForumInputGuard.isPositiveId(guildId) || threadId < 0) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); @@ -108,4 +113,4 @@ public class GuildForumPostThreadEvent extends MessageHandler { this.client.sendResponse(new ConnectionErrorComposer(500)); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java index 741a818f..0d8eae0b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java @@ -27,6 +27,11 @@ public class GuildForumThreadUpdateEvent extends MessageHandler { boolean isPinned = this.packet.readBoolean(); boolean isLocked = this.packet.readBoolean(); + if (!GuildForumInputGuard.isPositiveId(guildId) || !GuildForumInputGuard.isPositiveId(threadId)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -71,4 +76,4 @@ public class GuildForumThreadUpdateEvent extends MessageHandler { this.client.sendResponse(new GuildForumThreadsComposer(guild, 0)); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java index 8cde29ee..b4c4cfe6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java @@ -22,6 +22,11 @@ public class GuildForumThreadsEvent extends MessageHandler { int guildId = packet.readInt(); int index = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || index < 0) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); if (guild == null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java index 9d490352..909909d0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java @@ -29,6 +29,12 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler { int index = packet.readInt(); // 40 int limit = packet.readInt(); // 20 + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isPositiveId(threadId) || + !GuildForumInputGuard.isValidPage(index, limit)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -59,4 +65,4 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler { this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_ACCESS_DENIED.key).compose()); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java index 11916a7b..ed9748d8 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java @@ -23,6 +23,15 @@ public class GuildForumUpdateSettingsEvent extends MessageHandler { int postThreads = packet.readInt(); int modForum = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isSettingsState(canRead) || + !GuildForumInputGuard.isSettingsState(postMessages) || + !GuildForumInputGuard.isSettingsState(postThreads) || + !GuildForumInputGuard.isSettingsState(modForum)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); if (guild == null) { @@ -48,4 +57,4 @@ public class GuildForumUpdateSettingsEvent extends MessageHandler { this.client.sendResponse(new GuildForumDataComposer(guild, this.client.getHabbo())); } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java index 09b4ebb3..c2b6820f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java @@ -11,10 +11,17 @@ public class ModToolAlertEvent extends MessageHandler { @Override public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { - Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(this.packet.readInt()); + int userId = this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + + Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (alertedUser != null) - Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, this.packet.readString(), SupportUserAlertedReason.ALERT); + Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, message, SupportUserAlertedReason.ALERT); } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.kick").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java index 98320092..591b1de9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java @@ -15,9 +15,13 @@ public class ModToolCloseTicketEvent extends MessageHandler { this.packet.readInt(); int ticketId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(ticketId) || state < 1 || state > 3) { + return; + } + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); - if (issue == null || issue.modId != this.client.getHabbo().getHabboInfo().getId()) + if (!ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())) return; Habbo sender = Emulator.getGameEnvironment().getHabboManager().getHabbo(issue.senderId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java new file mode 100644 index 00000000..f401a99a --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java @@ -0,0 +1,16 @@ +package com.eu.habbo.messages.incoming.modtool; + +final class ModToolInputGuard { + static final int MAX_MESSAGE_LENGTH = 1000; + + private ModToolInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isSafeMessage(String value) { + return value != null && !value.isEmpty() && value.length() <= MAX_MESSAGE_LENGTH; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java index 64655536..e2c69701 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java @@ -14,9 +14,17 @@ public class ModToolIssueChangeTopicEvent extends MessageHandler { this.packet.readInt(); int categoryId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(ticketId) || !ModToolTicketGuard.isPositiveId(categoryId)) { + return; + } + + if (Emulator.getGameEnvironment().getModToolManager().getCfhTopic(categoryId) == null) { + return; + } + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); - if (issue != null) { + if (ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())) { issue.category = categoryId; new UpdateModToolIssue(issue).run(); Emulator.getGameEnvironment().getModToolManager().updateTicketToMods(issue); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java index b237c5f5..2b0789f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java @@ -21,6 +21,10 @@ public class ModToolIssueDefaultSanctionEvent extends MessageHandler { ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(issueId); + if (issue == null) { + return; + } + if (issue.modId == this.client.getHabbo().getHabboInfo().getId()) { CfhTopic modToolCategory = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(category); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java index 6491a4bf..da01445a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java @@ -18,6 +18,13 @@ public class ModToolKickEvent extends MessageHandler { return; } - Emulator.getGameEnvironment().getModToolManager().kick(this.client.getHabbo(), Emulator.getGameEnvironment().getHabboManager().getHabbo(this.packet.readInt()), this.packet.readString()); + int userId = this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + + Emulator.getGameEnvironment().getModToolManager().kick(this.client.getHabbo(), Emulator.getGameEnvironment().getHabboManager().getHabbo(userId), message); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java index 172ad656..2bbd68ce 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java @@ -15,10 +15,17 @@ public class ModToolPickTicketEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { this.packet.readInt(); - ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(this.packet.readInt()); + int ticketId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(ticketId)) { + this.client.getHabbo().alert(Emulator.getTexts().getValue("support.ticket.picked.failed")); + return; + } + + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); if (issue != null) { - if (issue.state == ModToolTicketState.PICKED) { + if (!ModToolTicketGuard.canPick(issue)) { this.client.sendResponse(new ModToolIssueInfoComposer(issue)); this.client.getHabbo().alert(Emulator.getTexts().getValue("support.ticket.picked.failed")); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java index e0a155d9..cc08c125 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java @@ -13,17 +13,22 @@ public class ModToolReleaseTicketEvent extends MessageHandler { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int count = this.packet.readInt(); + if (!ModToolTicketGuard.isValidReleaseBatch(count)) { + return; + } + while (count != 0) { count--; int ticketId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(ticketId)) { + continue; + } + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); - if (issue == null) - continue; - - if (issue.modId != this.client.getHabbo().getHabboInfo().getId()) + if (!ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())) continue; issue.modId = 0; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java new file mode 100644 index 00000000..f265096d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java @@ -0,0 +1,30 @@ +package com.eu.habbo.messages.incoming.modtool; + +final class ModToolReportInputGuard { + static final int MAX_REPORT_MESSAGE_LENGTH = 1000; + static final int MAX_PRIVATE_CHAT_LOGS = 100; + static final int MAX_PRIVATE_CHAT_MESSAGE_LENGTH = 500; + + private ModToolReportInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isValidReportMessage(String value) { + return value != null && !value.isEmpty() && value.length() <= MAX_REPORT_MESSAGE_LENGTH; + } + + static boolean isValidChatLogMessage(String value) { + return value != null && value.length() <= MAX_PRIVATE_CHAT_MESSAGE_LENGTH; + } + + static boolean isValidPrivateChatLogCount(int count) { + return count > 0 && count <= MAX_PRIVATE_CHAT_LOGS; + } + + static boolean isPositiveId(int id) { + return id > 0; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java index 4eb2756e..0bc5df4d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java @@ -16,7 +16,13 @@ public class ModToolRequestIssueChatlogEvent extends MessageHandler { @Override public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { - ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(this.packet.readInt()); + int ticketId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(ticketId)) { + return; + } + + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); if (issue != null) { List chatlog = new ArrayList<>(); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java index 5f58f50e..2e2e3de1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java @@ -12,7 +12,13 @@ public class ModToolRequestRoomChatlogEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { this.packet.readInt(); - Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(this.packet.readInt()); + int roomId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(roomId)) { + return; + } + + Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); if (room != null) this.client.sendResponse(new ModToolRoomChatlogComposer(room, Emulator.getGameEnvironment().getModToolManager().getRoomChatlog(room.getId()))); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java index 6fec0a90..a674ab4c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java @@ -14,6 +14,10 @@ public class ModToolRequestRoomUserChatlogEvent extends MessageHandler { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int userId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(userId)) { + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (habbo != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java index a175cdd0..f4ab8040 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java @@ -13,6 +13,11 @@ public class ModToolRequestUserChatlogEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int userId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(userId)) { + return; + } + HabboInfo habboInfo = HabboManager.getOfflineHabboInfo(userId); if (habboInfo == null) { return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java index 99f0bc0a..db84edf3 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java @@ -11,10 +11,15 @@ public class ModToolRoomAlertEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); if (room != null) { - room.alert(this.packet.readString()); + room.alert(message); } } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.roomalert").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java index cb609001..c37f1f52 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java @@ -21,9 +21,13 @@ public class ModToolSanctionAlertEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int cfhTopic = this.packet.readInt(); + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java index 5a2c8afa..d23f943a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java @@ -30,13 +30,17 @@ public class ModToolSanctionBanEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int cfhTopic = this.packet.readInt(); int banType = this.packet.readInt(); this.packet.readBoolean(); int duration = 0; + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + switch (banType) { case BAN_18_HOURS: duration = 18 * 60 * 60; @@ -79,4 +83,4 @@ public class ModToolSanctionBanEvent extends MessageHandler { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.ban").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java index 16a2bbdd..e5f91ab2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java @@ -23,9 +23,13 @@ public class ModToolSanctionMuteEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int cfhTopic = this.packet.readInt(); + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java index 9f666868..b49134f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java @@ -21,10 +21,14 @@ public class ModToolSanctionTradeLockEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int duration = this.packet.readInt(); int cfhTopic = this.packet.readInt(); + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java new file mode 100644 index 00000000..a5844c6d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java @@ -0,0 +1,28 @@ +package com.eu.habbo.messages.incoming.modtool; + +import com.eu.habbo.habbohotel.modtool.ModToolIssue; +import com.eu.habbo.habbohotel.modtool.ModToolTicketState; +import com.eu.habbo.habbohotel.users.Habbo; + +final class ModToolTicketGuard { + static final int MAX_RELEASE_BATCH = 50; + + private ModToolTicketGuard() { + } + + static boolean isPositiveId(int id) { + return id > 0; + } + + static boolean isValidReleaseBatch(int count) { + return count > 0 && count <= MAX_RELEASE_BATCH; + } + + static boolean isOwnedBy(ModToolIssue issue, Habbo moderator) { + return issue != null && moderator != null && issue.modId == moderator.getHabboInfo().getId(); + } + + static boolean canPick(ModToolIssue issue) { + return issue != null && issue.state != ModToolTicketState.PICKED; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java index a8dae64f..06c118b2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java @@ -16,10 +16,17 @@ public class ModToolWarnEvent extends MessageHandler { @Override public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { - Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(this.packet.readInt()); + int userId = this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + + Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (alertedUser != null) - Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, this.packet.readString(), SupportUserAlertedReason.CAUTION); + Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, message, SupportUserAlertedReason.CAUTION); } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.kick").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java index f2207f2c..7943cbc2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java @@ -14,7 +14,7 @@ import java.util.ArrayList; public class ReportBullyEvent extends MessageHandler { @Override public void handle() throws Exception { - if (this.client.getHabbo().getHabboStats().allowTalk()) { + if (!this.client.getHabbo().getHabboStats().allowTalk()) { this.client.sendResponse(new HelperRequestDisabledComposer()); return; } @@ -22,7 +22,9 @@ public class ReportBullyEvent extends MessageHandler { int userId = this.packet.readInt(); int roomId = this.packet.readInt(); - if (userId == this.client.getHabbo().getHabboInfo().getId()) { + if (!ModToolReportInputGuard.isPositiveId(userId) || + !ModToolReportInputGuard.isPositiveId(roomId) || + userId == this.client.getHabbo().getHabboInfo().getId()) { return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java index b21849ba..85673001 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java @@ -17,7 +17,15 @@ public class ReportCommentEvent extends MessageHandler { int threadId = this.packet.readInt(); int commentId = this.packet.readInt(); int topicId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); + + if (!ModToolReportInputGuard.isPositiveId(groupId) || + !ModToolReportInputGuard.isPositiveId(threadId) || + !ModToolReportInputGuard.isPositiveId(commentId) || + !ModToolReportInputGuard.isPositiveId(topicId) || + !ModToolReportInputGuard.isValidReportMessage(message)) { + return; + } CfhTopic topic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topicId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java index 99c8ba6c..aabb2dbc 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java @@ -21,12 +21,26 @@ public class ReportEvent extends MessageHandler { return; } - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); int topic = this.packet.readInt(); int userId = this.packet.readInt(); int roomId = this.packet.readInt(); this.packet.readInt(); + if (!ModToolReportInputGuard.isValidReportMessage(message) || + topic <= 0 || + (userId != -1 && !ModToolReportInputGuard.isPositiveId(userId)) || + !ModToolReportInputGuard.isPositiveId(roomId) || + userId == this.client.getHabbo().getHabboInfo().getId()) { + return; + } + + CfhTopic cfhTopic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topic); + + if (cfhTopic == null) { + return; + } + Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); List issues = Emulator.getGameEnvironment().getModToolManager().openTicketsForHabbo(this.client.getHabbo()); if (!issues.isEmpty()) { @@ -35,8 +49,6 @@ public class ReportEvent extends MessageHandler { return; } - CfhTopic cfhTopic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topic); - if (userId != -1) { Habbo reported = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); @@ -117,4 +129,4 @@ public class ReportEvent extends MessageHandler { } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java index 63e1ba43..4840679d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java @@ -21,12 +21,20 @@ public class ReportFriendPrivateChatEvent extends MessageHandler { return; } - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); int category = this.packet.readInt(); int userId = this.packet.readInt(); int count = this.packet.readInt(); ArrayList chatLogs = new ArrayList<>(); + if (!ModToolReportInputGuard.isValidReportMessage(message) || + category <= 0 || + !ModToolReportInputGuard.isPositiveId(userId) || + userId == this.client.getHabbo().getHabboInfo().getId() || + !ModToolReportInputGuard.isValidPrivateChatLogCount(count)) { + return; + } + HabboInfo info; Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (target != null) { @@ -37,11 +45,17 @@ public class ReportFriendPrivateChatEvent extends MessageHandler { if (info == null) return; - for (int i = 0; i < Math.min(count, 100); i++) { + for (int i = 0; i < count; i++) { int chatUserId = this.packet.readInt(); String username = this.packet.readInt() == info.getId() ? info.getUsername() : this.client.getHabbo().getHabboInfo().getUsername(); + String chatMessage = ModToolReportInputGuard.normalize(this.packet.readString()); - chatLogs.add(new ModToolChatLog(0, chatUserId, username, this.packet.readString())); + if (!ModToolReportInputGuard.isPositiveId(chatUserId) || + !ModToolReportInputGuard.isValidChatLogMessage(chatMessage)) { + return; + } + + chatLogs.add(new ModToolChatLog(0, chatUserId, username, chatMessage)); } ModToolIssue issue = new ModToolIssue(this.client.getHabbo().getHabboInfo().getId(), this.client.getHabbo().getHabboInfo().getUsername(), userId, info.getUsername(), 0, message, ModToolTicketType.IM); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java index fc225fb5..fc6e1f04 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java @@ -28,6 +28,12 @@ public class ReportPhotoEvent extends MessageHandler { int topicId = this.packet.readInt(); int itemId = this.packet.readInt(); + if (!ModToolReportInputGuard.isPositiveId(roomId) || + !ModToolReportInputGuard.isPositiveId(topicId) || + !ModToolReportInputGuard.isPositiveId(itemId)) { + return; + } + CfhTopic topic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topicId); if (topic == null) return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java index 50bcab89..b1e0b8b1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java @@ -16,7 +16,14 @@ public class ReportThreadEvent extends MessageHandler { int groupId = this.packet.readInt(); int threadId = this.packet.readInt(); int topicId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); + + if (!ModToolReportInputGuard.isPositiveId(groupId) || + !ModToolReportInputGuard.isPositiveId(threadId) || + !ModToolReportInputGuard.isPositiveId(topicId) || + !ModToolReportInputGuard.isValidReportMessage(message)) { + return; + } CfhTopic topic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topicId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index c52ef526..6d67512d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -607,6 +607,8 @@ public class Outgoing { public static final int WheelAdminPrizesComposer = 9404; public static final int SoundboardSettingsComposer = 9405; public static final int SoundboardPlayComposer = 9406; + public static final int EarningsCenterComposer = 9407; + public static final int EarningsClaimResultComposer = 9408; public static final int MentionReceivedComposer = 4801; public static final int MentionsListComposer = 4802; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java new file mode 100644 index 00000000..9330cecf --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java @@ -0,0 +1,44 @@ +package com.eu.habbo.messages.outgoing.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsEntry; +import com.eu.habbo.habbohotel.earnings.EarningsReward; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class EarningsCenterComposer extends MessageComposer { + private final List entries; + + public EarningsCenterComposer(List entries) { + this.entries = entries; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.EarningsCenterComposer); + this.response.appendInt(this.entries.size()); + + for (EarningsEntry entry : this.entries) { + serializeEntry(entry); + } + + return this.response; + } + + private void serializeEntry(EarningsEntry entry) { + this.response.appendString(entry.getCategory().getKey()); + this.response.appendBoolean(entry.isEnabled()); + this.response.appendBoolean(entry.isClaimable()); + this.response.appendInt(entry.getNextClaimAt()); + this.response.appendInt(entry.getRewards().size()); + + for (EarningsReward reward : entry.getRewards()) { + this.response.appendString(reward.getType()); + this.response.appendInt(reward.getAmount()); + this.response.appendInt(reward.getPointsType()); + this.response.appendString(reward.getData()); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java new file mode 100644 index 00000000..8b807896 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java @@ -0,0 +1,57 @@ +package com.eu.habbo.messages.outgoing.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsClaimResult; +import com.eu.habbo.habbohotel.earnings.EarningsEntry; +import com.eu.habbo.habbohotel.earnings.EarningsReward; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class EarningsClaimResultComposer extends MessageComposer { + private final List results; + + public EarningsClaimResultComposer(EarningsClaimResult result) { + this.results = List.of(result); + } + + public EarningsClaimResultComposer(List results) { + this.results = results; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.EarningsClaimResultComposer); + this.response.appendInt(this.results.size()); + + for (EarningsClaimResult result : this.results) { + this.response.appendString(result.getCategoryKey()); + this.response.appendString(result.getStatus().name().toLowerCase()); + this.response.appendBoolean(result.isSuccess()); + serializeEntry(result.getEntry()); + } + + return this.response; + } + + private void serializeEntry(EarningsEntry entry) { + this.response.appendBoolean(entry != null); + if (entry == null) { + return; + } + + this.response.appendString(entry.getCategory().getKey()); + this.response.appendBoolean(entry.isEnabled()); + this.response.appendBoolean(entry.isClaimable()); + this.response.appendInt(entry.getNextClaimAt()); + this.response.appendInt(entry.getRewards().size()); + + for (EarningsReward reward : entry.getRewards()) { + this.response.appendString(reward.getType()); + this.response.appendInt(reward.getAmount()); + this.response.appendInt(reward.getPointsType()); + this.response.appendString(reward.getData()); + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java new file mode 100644 index 00000000..29a75cec --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java @@ -0,0 +1,281 @@ +package com.eu.habbo.habbohotel.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ClaimRepository; +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ConfigSource; +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.RewardApplier; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +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 EarningsCenterManagerTest { + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.ofEpochSecond(1_800_000_000L), ZoneOffset.UTC); + + @Test + void disabledFeatureReturnsDisabledEntriesAndRejectsClaims() { + TestConfig config = new TestConfig().with("earnings.enabled", "0"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + List entries = manager.getEntries(null); + EarningsClaimResult result = manager.claim(null, "daily_gift"); + + assertFalse(entries.getFirst().isEnabled()); + assertFalse(entries.getFirst().isClaimable()); + assertEquals(EarningsClaimResult.Status.DISABLED, result.getStatus()); + assertTrue(rewards.granted.isEmpty()); + } + + @Test + void unknownCategoryIsRejected() { + EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsClaimResult result = manager.claim(null, "not_real"); + + assertEquals(EarningsClaimResult.Status.UNKNOWN_CATEGORY, result.getStatus()); + } + + @Test + void successfulClaimGrantsConfiguredRewardOnce() { + TestConfig config = enabledConfig() + .with("earnings.daily_gift.credits", "25") + .with("earnings.daily_gift.points", "3") + .with("earnings.daily_gift.points.type", "7"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + EarningsClaimResult first = manager.claim(null, "daily_gift"); + EarningsClaimResult duplicate = manager.claim(null, "daily_gift"); + + assertEquals(EarningsClaimResult.Status.SUCCESS, first.getStatus()); + assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, duplicate.getStatus()); + assertEquals(2, rewards.granted.size()); + assertEquals(EarningsReward.TYPE_CREDITS, rewards.granted.get(0).getType()); + assertEquals(25, rewards.granted.get(0).getAmount()); + assertEquals(EarningsReward.TYPE_POINTS, rewards.granted.get(1).getType()); + assertEquals(7, rewards.granted.get(1).getPointsType()); + } + + @Test + void categoryWithNoConfiguredRewardIsNotClaimable() { + EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsClaimResult result = manager.claim(null, "games"); + + assertEquals(EarningsClaimResult.Status.NO_REWARD, result.getStatus()); + assertFalse(result.getEntry().isClaimable()); + } + + @Test + void configurableBadgeItemAndHcRewardsAreIncludedInEntryState() { + TestConfig config = enabledConfig() + .with("earnings.bonus_bag.badge", "ACH_Test1") + .with("earnings.bonus_bag.item_id", "123") + .with("earnings.bonus_bag.item.quantity", "2") + .with("earnings.bonus_bag.hc.days", "7"); + EarningsCenterManager manager = new EarningsCenterManager(config, new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsEntry entry = manager.getEntries(null).stream() + .filter(current -> current.getCategory() == EarningsCategory.BONUS_BAG) + .findFirst() + .orElseThrow(); + + assertTrue(entry.isClaimable()); + assertEquals(3, entry.getRewards().size()); + assertEquals(EarningsReward.TYPE_BADGE, entry.getRewards().get(0).getType()); + assertEquals("ACH_Test1", entry.getRewards().get(0).getData()); + assertEquals(EarningsReward.TYPE_ITEM, entry.getRewards().get(1).getType()); + assertEquals("123", entry.getRewards().get(1).getData()); + assertEquals(2, entry.getRewards().get(1).getAmount()); + assertEquals(EarningsReward.TYPE_HC_DAYS, entry.getRewards().get(2).getType()); + assertEquals(7, entry.getRewards().get(2).getAmount()); + } + + @Test + void failedRewardGrantRollsBackClaimRecord() { + TestConfig config = enabledConfig().with("earnings.daily_gift.credits", "10"); + TestClaims claims = new TestClaims(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, (habbo, rewards) -> { + throw new SQLException("grant failed"); + }, FIXED_CLOCK); + + EarningsClaimResult failed = manager.claim(null, "daily_gift"); + EarningsClaimResult retried = new EarningsCenterManager(config, claims, new TestRewards(), FIXED_CLOCK) + .claim(null, "daily_gift"); + + assertEquals(EarningsClaimResult.Status.ERROR, failed.getStatus()); + assertEquals(EarningsClaimResult.Status.SUCCESS, retried.getStatus()); + } + + @Test + void nativeMarketplaceRowsUseNativeClaimInsteadOfPeriodicClaimLedger() { + TestConfig config = enabledConfig().with("earnings.marketplace.native.enabled", "1"); + TestClaims claims = new TestClaims(); + TestNativeIntegration nativeIntegration = new TestNativeIntegration(EarningsCategory.MARKETPLACE) + .withReward(new EarningsReward(EarningsReward.TYPE_CREDITS, 45, 0)); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, new TestRewards(), nativeIntegration, FIXED_CLOCK); + + EarningsEntry entry = manager.getEntries(null).stream() + .filter(current -> current.getCategory() == EarningsCategory.MARKETPLACE) + .findFirst() + .orElseThrow(); + EarningsClaimResult result = manager.claim(null, "marketplace"); + + assertTrue(entry.isClaimable()); + assertEquals(45, entry.getRewards().getFirst().getAmount()); + assertEquals(EarningsClaimResult.Status.SUCCESS, result.getStatus()); + assertEquals(1, nativeIntegration.claims); + assertTrue(claims.claims.isEmpty()); + } + + @Test + void nativeRowsWithoutAvailableRewardsAreNotClaimable() { + TestConfig config = enabledConfig().with("earnings.hc_payday.native.enabled", "1"); + TestNativeIntegration nativeIntegration = new TestNativeIntegration(EarningsCategory.HC_PAYDAY); + EarningsCenterManager manager = new EarningsCenterManager(config, new TestClaims(), new TestRewards(), nativeIntegration, FIXED_CLOCK); + + EarningsEntry entry = manager.getEntries(null).stream() + .filter(current -> current.getCategory() == EarningsCategory.HC_PAYDAY) + .findFirst() + .orElseThrow(); + EarningsClaimResult result = manager.claim(null, "hc_payday"); + + assertFalse(entry.isClaimable()); + assertEquals(EarningsClaimResult.Status.NO_REWARD, result.getStatus()); + assertEquals(0, nativeIntegration.claims); + } + + @Test + void claimAllGrantsClaimableRowsAndSkipsAlreadyClaimedRows() throws SQLException { + TestConfig config = enabledConfig() + .with("earnings.daily_gift.credits", "10") + .with("earnings.games.pixels", "4"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + claims.recordClaim(0, "daily_gift", String.valueOf(1_800_000_000L / 86400), 1_800_000_000); + List results = manager.claimAll(null); + + assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, results.get(0).getStatus()); + assertEquals(EarningsClaimResult.Status.SUCCESS, results.get(1).getStatus()); + assertEquals(1, rewards.granted.size()); + assertEquals(EarningsReward.TYPE_PIXELS, rewards.granted.getFirst().getType()); + assertEquals(4, rewards.granted.getFirst().getAmount()); + } + + private static TestConfig enabledConfig() { + return new TestConfig().with("earnings.enabled", "1"); + } + + private static class TestConfig implements ConfigSource { + private final Map values = new HashMap<>(); + + TestConfig with(String key, String value) { + this.values.put(key, value); + return this; + } + + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return this.values.getOrDefault(key, defaultValue ? "1" : "0").equals("1"); + } + + @Override + public int getInt(String key, int defaultValue) { + return Integer.parseInt(this.values.getOrDefault(key, String.valueOf(defaultValue))); + } + + @Override + public String getValue(String key, String defaultValue) { + return this.values.getOrDefault(key, defaultValue); + } + } + + private static class TestClaims implements ClaimRepository { + private final Set claims = new HashSet<>(); + + @Override + public boolean hasClaim(int userId, String category, String periodKey) { + return this.claims.contains(key(userId, category, periodKey)); + } + + @Override + public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) { + return this.claims.add(key(userId, category, periodKey)); + } + + @Override + public void removeClaim(int userId, String category, String periodKey) { + this.claims.remove(key(userId, category, periodKey)); + } + + private String key(int userId, String category, String periodKey) { + return userId + ":" + category + ":" + periodKey; + } + } + + private static class TestRewards implements RewardApplier { + private final List granted = new ArrayList<>(); + + @Override + public void grant(com.eu.habbo.habbohotel.users.Habbo habbo, List rewards) { + this.granted.addAll(rewards); + } + } + + private static class TestNativeIntegration implements EarningsCenterManager.NativeIntegration { + private final EarningsCategory category; + private final List rewards = new ArrayList<>(); + private int claims = 0; + + private TestNativeIntegration(EarningsCategory category) { + this.category = category; + } + + private TestNativeIntegration withReward(EarningsReward reward) { + this.rewards.add(reward); + return this; + } + + @Override + public boolean handles(EarningsCategory category) { + return this.category == category; + } + + @Override + public boolean hasClaim(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) { + return handles(category) && !this.rewards.isEmpty(); + } + + @Override + public List rewards(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) { + return handles(category) ? List.copyOf(this.rewards) : List.of(); + } + + @Override + public boolean claim(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) { + if (!hasClaim(habbo, category)) { + return false; + } + + this.claims++; + this.rewards.clear(); + return true; + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java new file mode 100644 index 00000000..d7c32f8b --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java @@ -0,0 +1,62 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuildForumInputGuardContractTest { + @Test + void forumHandlersValidateClientProvidedIds() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/guilds/forums"); + + for (String handler : List.of( + "GuildForumPostThreadEvent.java", + "GuildForumModerateMessageEvent.java", + "GuildForumModerateThreadEvent.java", + "GuildForumThreadUpdateEvent.java", + "GuildForumThreadsEvent.java", + "GuildForumThreadsMessagesEvent.java", + "GuildForumMarkAsReadEvent.java", + "GuildForumUpdateSettingsEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("GuildForumInputGuard.isPositiveId"), + handler + " must reject zero or negative client-provided ids"); + } + } + + @Test + void forumHandlersBoundExpensiveClientInputs() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/guilds/forums"); + + String messages = Files.readString(base.resolve("GuildForumThreadsMessagesEvent.java")); + String markRead = Files.readString(base.resolve("GuildForumMarkAsReadEvent.java")); + String settings = Files.readString(base.resolve("GuildForumUpdateSettingsEvent.java")); + String moderateThread = Files.readString(base.resolve("GuildForumModerateThreadEvent.java")); + String moderateMessage = Files.readString(base.resolve("GuildForumModerateMessageEvent.java")); + + assertTrue(messages.contains("GuildForumInputGuard.isValidPage(index, limit)"), + "thread message reads must bound index/limit before fetching comments"); + assertTrue(markRead.contains("GuildForumInputGuard.isValidMarkReadBatch(count)"), + "mark-as-read must bound the client-provided batch count before DB writes"); + assertTrue(settings.contains("GuildForumInputGuard.isSettingsState"), + "forum settings must reject unknown SettingsState values"); + assertTrue(moderateThread.contains("GuildForumInputGuard.isThreadModerationState(state)"), + "thread moderation must reject unknown ForumThreadState values"); + assertTrue(moderateMessage.contains("GuildForumInputGuard.isMessageModerationState(state)"), + "message moderation must reject unknown ForumThreadState values"); + } + + @Test + void forumPostsNormalizeTextBeforeFiltering() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java")); + + assertTrue(source.contains("GuildForumInputGuard.normalize(this.packet.readString())"), + "forum post subject and body should be normalized before word filtering and length checks"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java new file mode 100644 index 00000000..47189ba6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java @@ -0,0 +1,41 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +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 GuildForumInputGuardTest { + @Test + void normalizesNullableText() { + assertEquals("", GuildForumInputGuard.normalize(null)); + assertEquals("hello", GuildForumInputGuard.normalize(" hello ")); + } + + @Test + void validatesIdsAndPaging() { + assertFalse(GuildForumInputGuard.isPositiveId(0)); + assertTrue(GuildForumInputGuard.isPositiveId(1)); + assertFalse(GuildForumInputGuard.isValidPage(-1, 20)); + assertFalse(GuildForumInputGuard.isValidPage(0, 0)); + assertTrue(GuildForumInputGuard.isValidPage(0, GuildForumInputGuard.MAX_PAGE_LIMIT)); + assertFalse(GuildForumInputGuard.isValidPage(0, GuildForumInputGuard.MAX_PAGE_LIMIT + 1)); + } + + @Test + void validatesBatchAndStates() { + assertFalse(GuildForumInputGuard.isValidMarkReadBatch(0)); + assertTrue(GuildForumInputGuard.isValidMarkReadBatch(GuildForumInputGuard.MAX_MARK_READ_BATCH)); + assertFalse(GuildForumInputGuard.isValidMarkReadBatch(GuildForumInputGuard.MAX_MARK_READ_BATCH + 1)); + + assertTrue(GuildForumInputGuard.isSettingsState(0)); + assertTrue(GuildForumInputGuard.isSettingsState(3)); + assertFalse(GuildForumInputGuard.isSettingsState(4)); + + assertTrue(GuildForumInputGuard.isThreadModerationState(20)); + assertFalse(GuildForumInputGuard.isThreadModerationState(999)); + assertTrue(GuildForumInputGuard.isMessageModerationState(10)); + assertFalse(GuildForumInputGuard.isMessageModerationState(0)); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java new file mode 100644 index 00000000..214d2087 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java @@ -0,0 +1,23 @@ +package com.eu.habbo.messages.incoming.modtool; + +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 ModToolInputGuardTest { + @Test + void normalizesNullableMessages() { + assertEquals("", ModToolInputGuard.normalize(null)); + assertEquals("warn", ModToolInputGuard.normalize(" warn ")); + } + + @Test + void staffMessagesMustBeNonEmptyAndBounded() { + assertFalse(ModToolInputGuard.isSafeMessage(null)); + assertFalse(ModToolInputGuard.isSafeMessage("")); + assertTrue(ModToolInputGuard.isSafeMessage("a".repeat(ModToolInputGuard.MAX_MESSAGE_LENGTH))); + assertFalse(ModToolInputGuard.isSafeMessage("a".repeat(ModToolInputGuard.MAX_MESSAGE_LENGTH + 1))); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java index 460da889..ba03fc67 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java @@ -45,7 +45,7 @@ class ModToolPermissionContractTest { } @Test - void modToolSanctionsCannotTargetSameOrHigherRanks() throws Exception { + void modToolSanctionsCannotTargetPeerRanksUnlessOperatorIsCoreRank() throws Exception { Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); for (String handler : List.of( @@ -60,6 +60,53 @@ class ModToolPermissionContractTest { String manager = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java")); assertTrue(manager.contains("!canModerateTarget(moderator, target.getHabboInfo().getId())"), - "ModToolManager.alert must refuse alerts/warnings against same-or-higher-rank targets"); + "ModToolManager.alert must refuse alerts/warnings against protected targets"); + assertTrue(manager.contains("targetRankId < moderatorRankId"), + "non-core moderators must only target lower-ranked users"); + assertTrue(manager.contains("isCoreRank(moderatorRankId) && targetRankId <= moderatorRankId"), + "highest/core moderators should be allowed to target peer ranks"); + assertTrue(manager.contains("private static boolean isCoreRank(int rankId)"), + "core-rank detection should be centralized in ModToolManager"); + } + + @Test + void managerEntryPointsShareTargetAndRoomOwnerGuards() throws Exception { + String manager = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java")); + String sanctions = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java")); + String defaultSanction = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java")); + + assertTrue(manager.contains("!canModerateTarget(moderator, targetUserId)"), + "ModToolManager.ban must use the central target-rank guard for offline and online users"); + assertTrue(manager.contains("!canModerateTarget(moderator, h.getHabboInfo().getId())"), + "IP and machine fan-out bans must skip protected peer-or-higher ranked sessions"); + assertTrue(manager.contains("!canModerateTarget(moderator, room.getOwnerId())"), + "ModToolManager.roomAction must refuse mutations on rooms owned by protected ranks"); + assertTrue(sanctions.contains("!ModToolManager.canModerateTarget(self, habboId)"), + "ModToolSanctions.run must guard every sanction path before writing or applying it"); + assertTrue(defaultSanction.contains("if (issue == null)"), + "default sanctions must tolerate stale or missing ticket ids"); + } + + @Test + void staffSuppliedModToolMessagesAreBounded() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ModToolAlertEvent.java", + "ModToolWarnEvent.java", + "ModToolKickEvent.java", + "ModToolRoomAlertEvent.java", + "ModToolSanctionAlertEvent.java", + "ModToolSanctionBanEvent.java", + "ModToolSanctionMuteEvent.java", + "ModToolSanctionTradeLockEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolInputGuard.normalize"), + handler + " must normalize staff-supplied text before use"); + assertTrue(source.contains("ModToolInputGuard.isSafeMessage"), + handler + " must reject empty or oversized staff-supplied text"); + } } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java new file mode 100644 index 00000000..75f26644 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java @@ -0,0 +1,69 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModToolReportInputContractTest { + @Test + void reportHandlersNormalizeAndBoundFreeText() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ReportEvent.java", + "ReportFriendPrivateChatEvent.java", + "ReportCommentEvent.java", + "ReportThreadEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolReportInputGuard.normalize"), + handler + " must normalize report text before persistence or staff broadcast"); + assertTrue(source.contains("ModToolReportInputGuard.isValidReportMessage"), + handler + " must reject empty or oversized report text"); + } + } + + @Test + void reportHandlersRejectInvalidIdsAndCounts() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ReportEvent.java", + "ReportFriendPrivateChatEvent.java", + "ReportCommentEvent.java", + "ReportThreadEvent.java", + "ReportBullyEvent.java", + "ReportPhotoEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolReportInputGuard.isPositiveId"), + handler + " must reject zero or negative ids supplied by the client"); + } + + String privateChat = Files.readString(base.resolve("ReportFriendPrivateChatEvent.java")); + assertTrue(privateChat.contains("ModToolReportInputGuard.isValidPrivateChatLogCount(count)"), + "private chat reports must reject negative or oversized client-provided chatlog counts"); + } + + @Test + void reportEventValidatesTopicBeforeUsingReply() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java")); + + assertTrue(source.indexOf("if (cfhTopic == null)") < source.indexOf("cfhTopic.reply"), + "ReportEvent must reject unknown topics before dereferencing the reply text"); + } + + @Test + void bullyReportUsesSameMutedUserGateAsNormalReports() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java")); + + assertTrue(source.contains("if (!this.client.getHabbo().getHabboStats().allowTalk())"), + "bully reports must reject muted users instead of rejecting users who can talk"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java new file mode 100644 index 00000000..91e49a46 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.incoming.modtool; + +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 ModToolReportInputGuardTest { + @Test + void normalizesNullableMessages() { + assertEquals("", ModToolReportInputGuard.normalize(null)); + assertEquals("report", ModToolReportInputGuard.normalize(" report ")); + } + + @Test + void reportMessagesMustBeNonEmptyAndBounded() { + assertFalse(ModToolReportInputGuard.isValidReportMessage("")); + assertFalse(ModToolReportInputGuard.isValidReportMessage(null)); + assertTrue(ModToolReportInputGuard.isValidReportMessage("a".repeat(ModToolReportInputGuard.MAX_REPORT_MESSAGE_LENGTH))); + assertFalse(ModToolReportInputGuard.isValidReportMessage("a".repeat(ModToolReportInputGuard.MAX_REPORT_MESSAGE_LENGTH + 1))); + } + + @Test + void privateChatLogCountsAreBounded() { + assertFalse(ModToolReportInputGuard.isValidPrivateChatLogCount(0)); + assertTrue(ModToolReportInputGuard.isValidPrivateChatLogCount(ModToolReportInputGuard.MAX_PRIVATE_CHAT_LOGS)); + assertFalse(ModToolReportInputGuard.isValidPrivateChatLogCount(ModToolReportInputGuard.MAX_PRIVATE_CHAT_LOGS + 1)); + } + + @Test + void idsMustBePositive() { + assertFalse(ModToolReportInputGuard.isPositiveId(0)); + assertFalse(ModToolReportInputGuard.isPositiveId(-1)); + assertTrue(ModToolReportInputGuard.isPositiveId(1)); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java new file mode 100644 index 00000000..a6255d16 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java @@ -0,0 +1,22 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModToolTicketGuardTest { + @Test + void idsMustBePositive() { + assertFalse(ModToolTicketGuard.isPositiveId(0)); + assertFalse(ModToolTicketGuard.isPositiveId(-1)); + assertTrue(ModToolTicketGuard.isPositiveId(1)); + } + + @Test + void releaseBatchIsBounded() { + assertFalse(ModToolTicketGuard.isValidReleaseBatch(0)); + assertTrue(ModToolTicketGuard.isValidReleaseBatch(ModToolTicketGuard.MAX_RELEASE_BATCH)); + assertFalse(ModToolTicketGuard.isValidReleaseBatch(ModToolTicketGuard.MAX_RELEASE_BATCH + 1)); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java new file mode 100644 index 00000000..b74a7aee --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java @@ -0,0 +1,67 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModToolTicketLifecycleContractTest { + @Test + void mutatingTicketActionsValidateOwnership() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ModToolCloseTicketEvent.java", + "ModToolIssueChangeTopicEvent.java", + "ModToolReleaseTicketEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())"), + handler + " must only mutate tickets owned by the acting moderator"); + } + } + + @Test + void clientDrivenTicketAndChatlogIdsAreValidated() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ModToolPickTicketEvent.java", + "ModToolCloseTicketEvent.java", + "ModToolIssueChangeTopicEvent.java", + "ModToolRequestIssueChatlogEvent.java", + "ModToolRequestRoomChatlogEvent.java", + "ModToolRequestRoomUserChatlogEvent.java", + "ModToolRequestUserChatlogEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolTicketGuard.isPositiveId"), + handler + " must reject zero or negative client-provided ids"); + } + } + + @Test + void releaseBatchAndCloseStateAreBounded() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + String release = Files.readString(base.resolve("ModToolReleaseTicketEvent.java")); + String close = Files.readString(base.resolve("ModToolCloseTicketEvent.java")); + + assertTrue(release.contains("ModToolTicketGuard.isValidReleaseBatch(count)"), + "release ticket batches must be bounded before reading ticket ids"); + assertTrue(close.contains("state < 1 || state > 3"), + "close ticket must reject unknown close states before mutating the ticket"); + } + + @Test + void changeTopicRequiresKnownCategory() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java")); + + assertTrue(source.contains("getCfhTopic(categoryId) == null"), + "change-topic must reject unknown CFH categories before persisting"); + } +} diff --git a/docs/earnings-packet-contract.md b/docs/earnings-packet-contract.md new file mode 100644 index 00000000..604bb85c --- /dev/null +++ b/docs/earnings-packet-contract.md @@ -0,0 +1,97 @@ +# Earnings Center Packet Contract + +This document is the emulator-side contract for the "Guadagni" UI. + +## Incoming + +### `RequestEarningsCenterEvent` + +- Header: `9308` +- Body: empty +- Response: `EarningsCenterComposer` + +### `ClaimEarningsRewardEvent` + +- Header: `9309` +- Body: + - `String categoryKey` +- Response: `EarningsClaimResultComposer` + +### `ClaimAllEarningsRewardsEvent` + +- Header: `9310` +- Body: empty +- Response: `EarningsClaimResultComposer` + +## Outgoing + +### `EarningsCenterComposer` + +- Header: `9407` +- Body: + - `int entryCount` + - repeated entry: + - `String categoryKey` + - `boolean enabled` + - `boolean claimable` + - `int nextClaimAt` + - `int rewardCount` + - repeated reward: + - `String type` + - `int amount` + - `int pointsType` + - `String data` + +### `EarningsClaimResultComposer` + +- Header: `9408` +- Body: + - `int resultCount` + - repeated result: + - `String categoryKey` + - `String status` + - `boolean success` + - `boolean hasEntry` + - entry body when `hasEntry=true`, same shape as `EarningsCenterComposer` + +## Categories + +- `daily_gift` +- `games` +- `achievements` +- `marketplace` +- `hc_payday` +- `level_progress` +- `donations` +- `bonus_bag` +- `mystery_boxes` +- `club_job` + +## Reward Types + +- `credits` +- `pixels` +- `points` +- `badge` +- `item` +- `hc_days` + +For `points`, `pointsType` carries the currency type. For `badge`, `data` carries the badge code. For `item`, `data` carries the `items_base.id`. Other reward types keep `data` empty. + +`marketplace` and `hc_payday` can be native rows. In native mode the amounts come from existing server state: + +- `marketplace`: sold marketplace offers waiting for payout +- `hc_payday`: unclaimed rows in `logs_hc_payday` +- `achievements`: configured rewards gated by achievement score buckets +- `level_progress`: configured rewards gated by citizenship/helper talent level + +## Result Status + +- `success` +- `disabled` +- `unknown_category` +- `already_claimed` +- `no_reward` +- `error` + +The client must not send reward amounts. Claim eligibility and rewards are always server authoritative. diff --git a/docs/superpowers/plans/2026-06-15-earnings-center.md b/docs/superpowers/plans/2026-06-15-earnings-center.md new file mode 100644 index 00000000..9df4750b --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-earnings-center.md @@ -0,0 +1,89 @@ +# Earnings Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an emulator-owned earnings/rewards hub for the new "Guadagni" UI, with server-side reward definitions and claim protection. + +**Architecture:** Add a focused earnings package under `com.eu.habbo.habbohotel.earnings`, wire three incoming handlers and two outgoing composers, and persist claims in a dedicated table with a unique period key. Keep reward definitions config-driven so UI/renderer work can progress independently. + +**Tech Stack:** Java 21, Maven, MariaDB SQL updates, existing Arcturus packet manager/composer patterns, JUnit tests. + +--- + +### Task 1: Map Existing Patterns + +**Files:** +- Read: `Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java` +- Read: `Emulator/src/main/java/com/eu/habbo/messages/PacketNames.java` +- Read: `Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java` +- Read: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/MessageComposer.java` + +- [ ] Inspect packet registration and composer header lookup. +- [ ] Inspect currency grant methods on `Habbo`. +- [ ] Inspect emulator setting access APIs. +- [ ] Choose the smallest implementation that matches existing style. + +### Task 2: Add Earnings Domain + +**Files:** +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java` + +- [ ] Define allowlisted categories and client keys. +- [ ] Load enabled flags, cooldowns, and reward values from configuration. +- [ ] Build row state for a user. +- [ ] Implement single claim and claim-all. +- [ ] Grant credits/pixels/points through existing `Habbo` APIs. +- [ ] Grant badges, furni items, and HC days through existing emulator storage paths. +- [ ] Roll back a claim marker if a DB-backed grant fails. + +### Task 3: Add Persistence + +**Files:** +- Create: `Database Updates/012_earnings_center.sql` + +- [ ] Create `users_earnings_claims`. +- [ ] Add unique key on `user_id`, `category`, `period_key`. +- [ ] Keep the migration additive and safe for existing databases. + +### Task 4: Add Packet Bridge + +**Files:** +- Create: `Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java` +- Modify: packet registration/mapping files discovered in Task 1. + +- [ ] Incoming handlers parse only category keys. +- [ ] Outgoing composers serialize rows and claim results. +- [ ] Packet names are documented for renderer alignment. + +### Task 5: Test and Build + +**Files:** +- Create: `Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java` + +- [ ] Test disabled feature behavior. +- [ ] Test unknown category rejection. +- [ ] Test single claim success. +- [ ] Test duplicate claim rejection. +- [ ] Test claim-all partial success. +- [ ] Test badge, item, and HC reward serialization state. +- [ ] Test claim rollback after grant failure. +- [ ] Run focused tests. +- [ ] Run `mvn clean package`. + +### Task 6: Commit and PR + +**Files:** +- Commit all source, test, SQL, spec, and plan files. + +- [ ] Commit spec and plan. +- [ ] Commit implementation. +- [ ] Push `feat/earnings-center` to `simoleo89/Arcturus-Morningstar-Extended`. +- [ ] Open ready-for-review PR to `duckietm/Arcturus-Morningstar-Extended:dev`. diff --git a/docs/superpowers/specs/2026-06-15-earnings-center-design.md b/docs/superpowers/specs/2026-06-15-earnings-center-design.md new file mode 100644 index 00000000..07b13a2f --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-earnings-center-design.md @@ -0,0 +1,113 @@ +# Earnings Center Design + +## Goal + +Add an emulator-owned rewards hub for the "Guadagni" UI. The client and renderer may decide how it looks, but the emulator must own reward amounts, claim eligibility, cooldowns, and anti-abuse checks. + +## Scope + +The first emulator version exposes ten earnings categories: + +- `daily_gift` +- `games` +- `achievements` +- `marketplace` +- `hc_payday` +- `level_progress` +- `donations` +- `bonus_bag` +- `mystery_boxes` +- `club_job` + +Every category can be enabled, disabled, configured with one or more reward currencies, and claimed through a single-row claim or a claim-all request. Categories that are not yet backed by a native hotel subsystem still work through static configuration, so the UI contract is stable while deeper integrations are added later. + +Supported configured reward types are credits, pixels/duckets, seasonal points, badges, furni items, and HC days. + +## Architecture + +Add a focused `com.eu.habbo.habbohotel.earnings` package: + +- `EarningsCenterManager` loads category definitions from emulator settings, builds per-user state, and performs claims. +- `EarningsCategory` is the allowlisted category enum and carries the client key. +- `EarningsReward` represents one configured reward. +- `EarningsEntry` is the serializable row state sent to the client. +- `EarningsClaimResult` reports single/all claim outcomes. + +The packet layer only parses category keys and delegates to the manager. The client never sends amounts, cooldowns, or reward definitions. + +## Persistence + +Add a database update that creates `users_earnings_claims`: + +- `id` +- `user_id` +- `category` +- `period_key` +- `claimed_at` +- unique key on `user_id`, `category`, `period_key` + +The unique key is the main double-claim guard. `period_key` is calculated by the emulator from the category cooldown. Daily-style rewards use the UTC date key by default. One-time or long cooldown rows can use the cooldown bucket derived from `claimed_at`. + +## Configuration + +Add emulator settings with safe defaults: + +- `earnings.enabled=0` +- `earnings..enabled=1` +- `earnings..cooldown.seconds=86400` +- `earnings..credits=0` +- `earnings..pixels=0` +- `earnings..points=0` +- `earnings..points.type=5` +- `earnings..badge=` +- `earnings..item_id=0` +- `earnings..item.quantity=1` +- `earnings..hc.days=0` +- `earnings..native.enabled=0/1` + +The feature defaults off so existing hotels do not receive surprise economy changes after deploying the jar. +Marketplace and HC payday default to native integrations once the feature is enabled, because both already have server-side claim ledgers. +Achievements and level progress use native eligibility by default: achievement score buckets and talent-track levels decide when the configured reward may be claimed. + +## Packet Contract + +Add three incoming handlers: + +- `RequestEarningsCenterEvent` +- `ClaimEarningsRewardEvent` +- `ClaimAllEarningsRewardsEvent` + +Add two outgoing composers: + +- `EarningsCenterComposer` +- `EarningsClaimResultComposer` + +Composer format is intentionally simple and renderer-friendly: category key, enabled state, claimable state, next claim timestamp, rewards, and result code. Header IDs must be wired through `messages.ini`/packet registration in the same style as the rest of the emulator. If the renderer side chooses final IDs later, only the packet mapping should need adjustment. + +## Security + +- Reject unknown category keys. +- Reject all claims when `earnings.enabled=0`. +- Never trust reward amounts from the client. +- Clamp configured rewards to non-negative values and bounded item/HC limits. +- Roll back the claim record if a DB-backed reward grant fails. +- Use the database unique key to prevent concurrent double claims. +- `claim all` processes only claimable rows and returns per-category results. +- Marketplace claims use the existing marketplace sold-offer payout path. +- HC payday claims use existing unclaimed `logs_hc_payday` rows. +- Achievement claims can be scoped to score buckets via `earnings.achievements.score.step`. +- Level progress claims can be scoped to the current highest citizenship/helper level. + +## Tests + +Add unit tests around the manager-level logic: + +- disabled global feature returns disabled rows and rejects claims +- unknown category is rejected +- successful claim grants configured currency once +- duplicate claim in the same period is rejected +- claim-all grants all claimable rows and skips already claimed rows +- badge/item/HC reward rows are included in state +- failed reward grants roll back the claim record + +Packet tests can remain light because renderer IDs may be finalized separately; the critical behavior is the server-side claim guard.