Merge branch 'dev' into fix/catalog-inventory-safety

This commit is contained in:
DuckieTM
2026-06-15 22:17:00 +02:00
committed by GitHub
64 changed files with 2398 additions and 51 deletions
+132
View File
@@ -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.');
@@ -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;
}
@@ -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<EarningsCategory> 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();
}
}
@@ -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<EarningsEntry> getEntries(Habbo habbo) {
int userId = getUserId(habbo);
int now = now();
List<EarningsEntry> 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<EarningsCategory> requestedCategory = EarningsCategory.fromKey(categoryKey);
if (requestedCategory.isEmpty()) {
return new EarningsClaimResult(null, EarningsClaimResult.Status.UNKNOWN_CATEGORY, null);
}
return claim(habbo, requestedCategory.get());
}
public List<EarningsClaimResult> claimAll(Habbo habbo) {
List<EarningsClaimResult> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> rewards) throws SQLException;
}
public interface NativeIntegration {
boolean handles(EarningsCategory category);
boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException;
List<EarningsReward> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> 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<EarningsReward> hcPaydayRewards(Habbo habbo) throws SQLException {
List<EarningsReward> 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;
}
}
};
}
}
}
@@ -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;
}
}
@@ -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<EarningsReward> rewards;
public EarningsEntry(EarningsCategory category, boolean enabled, boolean claimable, int nextClaimAt, List<EarningsReward> 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<EarningsReward> getRewards() {
return rewards;
}
}
@@ -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;
}
}
@@ -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<ModToolBan> 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);
@@ -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);
@@ -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);
}
}
@@ -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;
@@ -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())));
}
}
@@ -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)));
}
}
@@ -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())));
}
}
@@ -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;
}
}
@@ -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` = ?"
@@ -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 {
}
}
}
}
@@ -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);
}
}
}
}
@@ -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));
}
}
}
}
@@ -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));
}
}
}
}
@@ -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) {
@@ -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());
}
}
}
}
@@ -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()));
}
}
}
@@ -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()));
}
@@ -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);
@@ -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;
}
}
@@ -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);
@@ -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);
@@ -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);
}
}
@@ -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"));
@@ -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;
@@ -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;
}
}
@@ -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<ModToolChatLog> chatlog = new ArrayList<>();
@@ -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())));
@@ -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) {
@@ -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;
@@ -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()));
@@ -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);
@@ -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()));
}
}
}
}
@@ -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);
@@ -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);
@@ -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;
}
}
@@ -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()));
}
@@ -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;
}
@@ -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);
@@ -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<ModToolIssue> 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 {
}
}
}
}
@@ -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<ModToolChatLog> 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);
@@ -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;
@@ -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);
@@ -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;
@@ -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<EarningsEntry> entries;
public EarningsCenterComposer(List<EarningsEntry> 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());
}
}
}
@@ -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<EarningsClaimResult> results;
public EarningsClaimResultComposer(EarningsClaimResult result) {
this.results = List.of(result);
}
public EarningsClaimResultComposer(List<EarningsClaimResult> 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());
}
}
}
@@ -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<EarningsEntry> 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<EarningsClaimResult> 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<String, String> 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<String> 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<EarningsReward> granted = new ArrayList<>();
@Override
public void grant(com.eu.habbo.habbohotel.users.Habbo habbo, List<EarningsReward> rewards) {
this.granted.addAll(rewards);
}
}
private static class TestNativeIntegration implements EarningsCenterManager.NativeIntegration {
private final EarningsCategory category;
private final List<EarningsReward> 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<EarningsReward> 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;
}
}
}
@@ -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");
}
}
@@ -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));
}
}
@@ -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)));
}
}
@@ -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");
}
}
}
@@ -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");
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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");
}
}
+97
View File
@@ -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.
@@ -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`.
@@ -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.<category>.enabled=1`
- `earnings.<category>.cooldown.seconds=86400`
- `earnings.<category>.credits=0`
- `earnings.<category>.pixels=0`
- `earnings.<category>.points=0`
- `earnings.<category>.points.type=5`
- `earnings.<category>.badge=`
- `earnings.<category>.item_id=0`
- `earnings.<category>.item.quantity=1`
- `earnings.<category>.hc.days=0`
- `earnings.<category>.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.