From e29e06201c2265c0a1b1b60182c6f23b29eb8258 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:41:00 +0200 Subject: [PATCH] feat(earnings): add emulator rewards center --- Database Updates/012_earnings_center.sql | 115 ++++++ .../src/main/java/com/eu/habbo/Emulator.java | 38 ++ .../habbohotel/earnings/EarningsCategory.java | 38 ++ .../earnings/EarningsCenterManager.java | 347 ++++++++++++++++++ .../earnings/EarningsClaimResult.java | 42 +++ .../habbohotel/earnings/EarningsEntry.java | 39 ++ .../habbohotel/earnings/EarningsReward.java | 42 +++ .../com/eu/habbo/messages/PacketManager.java | 8 + .../eu/habbo/messages/incoming/Incoming.java | 3 + .../ClaimAllEarningsRewardsEvent.java | 18 + .../earnings/ClaimEarningsRewardEvent.java | 19 + .../earnings/RequestEarningsCenterEvent.java | 18 + .../eu/habbo/messages/outgoing/Outgoing.java | 2 + .../earnings/EarningsCenterComposer.java | 44 +++ .../earnings/EarningsClaimResultComposer.java | 57 +++ .../earnings/EarningsCenterManagerTest.java | 202 ++++++++++ .../plans/2026-06-15-earnings-center.md | 4 + .../2026-06-15-earnings-center-design.md | 11 +- 18 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 Database Updates/012_earnings_center.sql create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java diff --git a/Database Updates/012_earnings_center.sql b/Database Updates/012_earnings_center.sql new file mode 100644 index 00000000..d20f5afa --- /dev/null +++ b/Database Updates/012_earnings_center.sql @@ -0,0 +1,115 @@ +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.'); diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 720b6af0..8d4a95fe 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -160,6 +160,13 @@ public final class Emulator { Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); Emulator.config.register("gui.enabled", "0"); Emulator.config.register("gui.autostart.enabled", "0"); + Emulator.config.register("rcon.rate_limit.enabled", "1"); + Emulator.config.register("rcon.rate_limit.limit_for_period", "60"); + Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000"); + Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); + Emulator.config.register("rcon.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!"); @@ -482,6 +489,37 @@ 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"); + } + } + public static RCONServer getRconServer() { return rconServer; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java new file mode 100644 index 00000000..547df6c5 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java @@ -0,0 +1,38 @@ +package com.eu.habbo.habbohotel.earnings; + +import java.util.Arrays; +import java.util.Optional; + +public enum EarningsCategory { + DAILY_GIFT("daily_gift"), + GAMES("games"), + ACHIEVEMENTS("achievements"), + MARKETPLACE("marketplace"), + HC_PAYDAY("hc_payday"), + LEVEL_PROGRESS("level_progress"), + DONATIONS("donations"), + BONUS_BAG("bonus_bag"), + MYSTERY_BOXES("mystery_boxes"), + CLUB_JOB("club_job"); + + private final String key; + + EarningsCategory(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + public static Optional fromKey(String key) { + if (key == null || key.isBlank()) { + return Optional.empty(); + } + + String normalized = key.trim().toLowerCase(); + return Arrays.stream(values()) + .filter(category -> category.key.equals(normalized)) + .findFirst(); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java new file mode 100644 index 00000000..32514c51 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java @@ -0,0 +1,347 @@ +package com.eu.habbo.habbohotel.earnings; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboBadge; +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 Clock clock; + + public EarningsCenterManager() { + this(new EmulatorConfigSource(), new JdbcClaimRepository(), new HabboRewardApplier(), Clock.systemUTC()); + } + + public EarningsCenterManager(ConfigSource config, ClaimRepository claims, RewardApplier rewards, Clock clock) { + this.config = config; + this.claims = claims; + this.rewards = rewards; + this.clock = clock; + } + + public List getEntries(Habbo habbo) { + int userId = getUserId(habbo); + int now = now(); + List entries = new ArrayList<>(); + + for (EarningsCategory category : EarningsCategory.values()) { + entries.add(buildEntry(userId, category, now)); + } + + return entries; + } + + public EarningsClaimResult claim(Habbo habbo, String categoryKey) { + Optional requestedCategory = EarningsCategory.fromKey(categoryKey); + if (requestedCategory.isEmpty()) { + return new EarningsClaimResult(null, EarningsClaimResult.Status.UNKNOWN_CATEGORY, null); + } + + return claim(habbo, requestedCategory.get()); + } + + public List claimAll(Habbo habbo) { + List results = new ArrayList<>(); + + for (EarningsCategory category : EarningsCategory.values()) { + results.add(claim(habbo, category)); + } + + return results; + } + + private EarningsClaimResult claim(Habbo habbo, EarningsCategory category) { + int userId = getUserId(habbo); + int now = now(); + CategoryDefinition definition = loadDefinition(category); + + if (!definition.enabled()) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.DISABLED, buildEntry(userId, category, now)); + } + + if (definition.rewards().isEmpty()) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(userId, category, now)); + } + + String periodKey = periodKey(now, definition.cooldownSeconds()); + + try { + if (!this.claims.recordClaim(userId, category.getKey(), periodKey, now)) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.ALREADY_CLAIMED, buildEntry(userId, category, now)); + } + + this.rewards.grant(habbo, definition.rewards()); + return new EarningsClaimResult(category, EarningsClaimResult.Status.SUCCESS, buildEntry(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(userId, category, now)); + } + } + + private EarningsEntry buildEntry(int userId, EarningsCategory category, int now) { + CategoryDefinition definition = loadDefinition(category); + boolean claimable = false; + int nextClaimAt = 0; + + if (definition.enabled() && !definition.rewards().isEmpty()) { + String periodKey = periodKey(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(EarningsCategory category) { + String key = CONFIG_PREFIX + category.getKey() + "."; + boolean enabled = this.config.getBoolean(CONFIG_PREFIX + "enabled", false) + && this.config.getBoolean(key + "enabled", true); + int cooldown = Math.max(60, this.config.getInt(key + "cooldown.seconds", DEFAULT_COOLDOWN_SECONDS)); + int pointsType = Math.max(0, this.config.getInt(key + "points.type", DEFAULT_POINTS_TYPE)); + List rewards = new ArrayList<>(); + + 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 void addReward(List rewards, String type, int amount, int pointsType) { + int clampedAmount = Math.min(Math.max(0, amount), MAX_CONFIGURED_REWARD); + if (clampedAmount > 0) { + rewards.add(new EarningsReward(type, clampedAmount, pointsType)); + } + } + + private void addBadgeReward(List rewards, String badgeCode) { + if (badgeCode == null || !badgeCode.matches("[A-Za-z0-9_\\-]{1,64}")) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_BADGE, 1, 0, badgeCode)); + } + + private void addItemReward(List rewards, int itemId, int quantity) { + if (itemId <= 0 || quantity <= 0) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_ITEM, Math.min(quantity, MAX_ITEM_QUANTITY), 0, String.valueOf(itemId))); + } + + private void addHcReward(List rewards, int days) { + if (days <= 0) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_HC_DAYS, Math.min(days, MAX_HC_DAYS), 0)); + } + + private int getUserId(Habbo habbo) { + if (habbo == null || habbo.getHabboInfo() == null) { + return 0; + } + + return habbo.getHabboInfo().getId(); + } + + private int now() { + return (int) (this.clock.instant().getEpochSecond()); + } + + private String periodKey(int now, int cooldownSeconds) { + return String.valueOf(now / cooldownSeconds); + } + + private int nextPeriodStart(int now, int cooldownSeconds) { + return ((now / cooldownSeconds) + 1) * cooldownSeconds; + } + + private record CategoryDefinition(boolean enabled, int cooldownSeconds, List rewards) { + } + + public interface ConfigSource { + boolean getBoolean(String key, boolean defaultValue); + + int getInt(String key, int defaultValue); + + String getValue(String key, String defaultValue); + } + + public interface ClaimRepository { + boolean hasClaim(int userId, String category, String periodKey) throws SQLException; + + boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException; + + void removeClaim(int userId, String category, String periodKey) throws SQLException; + } + + public interface RewardApplier { + void grant(Habbo habbo, List rewards) throws SQLException; + } + + private static class EmulatorConfigSource implements ConfigSource { + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return Emulator.getConfig().getBoolean(key, defaultValue); + } + + @Override + public int getInt(String key, int defaultValue) { + return Emulator.getConfig().getInt(key, defaultValue); + } + + @Override + public String getValue(String key, String defaultValue) { + return Emulator.getConfig().getValue(key, defaultValue); + } + } + + private static class JdbcClaimRepository implements ClaimRepository { + @Override + public boolean hasClaim(int userId, String category, String periodKey) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT 1 FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + return statement.executeQuery().next(); + } + } + + @Override + public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("INSERT INTO users_earnings_claims (user_id, category, period_key, claimed_at) VALUES (?, ?, ?, FROM_UNIXTIME(?))")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + statement.setInt(4, claimedAt); + return statement.executeUpdate() == 1; + } catch (SQLIntegrityConstraintViolationException duplicate) { + return false; + } + } + + @Override + public void removeClaim(int userId, String category, String periodKey) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + statement.executeUpdate(); + } + } + } + + private static class HabboRewardApplier implements RewardApplier { + @Override + public void grant(Habbo habbo, List rewards) throws SQLException { + if (habbo == null) { + return; + } + + for (EarningsReward reward : rewards) { + switch (reward.getType()) { + case EarningsReward.TYPE_CREDITS -> habbo.giveCredits(reward.getAmount()); + case EarningsReward.TYPE_PIXELS -> habbo.givePixels(reward.getAmount()); + case EarningsReward.TYPE_POINTS -> habbo.givePoints(reward.getPointsType(), reward.getAmount()); + case EarningsReward.TYPE_BADGE -> grantBadge(habbo, reward.getData()); + case EarningsReward.TYPE_ITEM -> grantItem(habbo, Integer.parseInt(reward.getData()), reward.getAmount()); + case EarningsReward.TYPE_HC_DAYS -> grantHcDays(habbo, reward.getAmount()); + default -> { + } + } + } + } + + private void grantBadge(Habbo habbo, String badgeCode) throws SQLException { + if (habbo.getInventory().getBadgesComponent().hasBadge(badgeCode)) { + return; + } + + HabboBadge badge = new HabboBadge(0, badgeCode, 0, habbo); + badge.run(); + habbo.getInventory().getBadgesComponent().addBadge(badge); + if (habbo.getClient() != null) { + habbo.getClient().sendResponse(new AddUserBadgeComposer(badge)); + } + } + + private void grantItem(Habbo habbo, int itemId, int quantity) throws SQLException { + if (!itemExists(itemId)) { + throw new SQLException("Unknown earnings item reward " + itemId); + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("INSERT INTO items (user_id, item_id, extra_data) VALUES (?, ?, '')")) { + for (int i = 0; i < quantity; i++) { + statement.setInt(1, habbo.getHabboInfo().getId()); + statement.setInt(2, itemId); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private boolean itemExists(int itemId) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT id FROM items_base WHERE id = ? LIMIT 1")) { + statement.setInt(1, itemId); + try (ResultSet set = statement.executeQuery()) { + return set.next(); + } + } + } + + private void grantHcDays(Habbo habbo, int days) throws SQLException { + int now = Emulator.getIntUnixTimestamp(); + int current = habbo.getHabboStats().getClubExpireTimestamp(); + int newExpire = (current > now ? current : now) + (days * 86400); + + habbo.getHabboStats().setClubExpireTimestamp(newExpire); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET club_expire_timestamp = ? WHERE user_id = ? LIMIT 1")) { + statement.setInt(1, newExpire); + statement.setInt(2, habbo.getHabboInfo().getId()); + statement.executeUpdate(); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java new file mode 100644 index 00000000..ab5dc896 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java @@ -0,0 +1,42 @@ +package com.eu.habbo.habbohotel.earnings; + +public class EarningsClaimResult { + public enum Status { + SUCCESS, + DISABLED, + UNKNOWN_CATEGORY, + ALREADY_CLAIMED, + NO_REWARD, + ERROR + } + + private final EarningsCategory category; + private final Status status; + private final EarningsEntry entry; + + public EarningsClaimResult(EarningsCategory category, Status status, EarningsEntry entry) { + this.category = category; + this.status = status; + this.entry = entry; + } + + public EarningsCategory getCategory() { + return category; + } + + public String getCategoryKey() { + return category == null ? "" : category.getKey(); + } + + public Status getStatus() { + return status; + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public EarningsEntry getEntry() { + return entry; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java new file mode 100644 index 00000000..2fdf2318 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java @@ -0,0 +1,39 @@ +package com.eu.habbo.habbohotel.earnings; + +import java.util.List; + +public class EarningsEntry { + private final EarningsCategory category; + private final boolean enabled; + private final boolean claimable; + private final int nextClaimAt; + private final List rewards; + + public EarningsEntry(EarningsCategory category, boolean enabled, boolean claimable, int nextClaimAt, List rewards) { + this.category = category; + this.enabled = enabled; + this.claimable = claimable; + this.nextClaimAt = Math.max(0, nextClaimAt); + this.rewards = List.copyOf(rewards); + } + + public EarningsCategory getCategory() { + return category; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isClaimable() { + return claimable; + } + + public int getNextClaimAt() { + return nextClaimAt; + } + + public List getRewards() { + return rewards; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java new file mode 100644 index 00000000..9531c6a7 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java @@ -0,0 +1,42 @@ +package com.eu.habbo.habbohotel.earnings; + +public class EarningsReward { + public static final String TYPE_CREDITS = "credits"; + public static final String TYPE_PIXELS = "pixels"; + public static final String TYPE_POINTS = "points"; + public static final String TYPE_BADGE = "badge"; + public static final String TYPE_ITEM = "item"; + public static final String TYPE_HC_DAYS = "hc_days"; + + private final String type; + private final int amount; + private final int pointsType; + private final String data; + + public EarningsReward(String type, int amount, int pointsType) { + this(type, amount, pointsType, ""); + } + + public EarningsReward(String type, int amount, int pointsType, String data) { + this.type = type; + this.amount = Math.max(0, amount); + this.pointsType = Math.max(0, pointsType); + this.data = data == null ? "" : data; + } + + public String getType() { + return type; + } + + public int getAmount() { + return amount; + } + + public int getPointsType() { + return pointsType; + } + + public String getData() { + return data; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index 9f372c5b..7bf5460b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -21,6 +21,7 @@ import com.eu.habbo.messages.incoming.catalog.recycler.RequestRecyclerLogicEvent import com.eu.habbo.messages.incoming.crafting.*; import com.eu.habbo.messages.incoming.events.calendar.AdventCalendarForceOpenEvent; import com.eu.habbo.messages.incoming.events.calendar.AdventCalendarOpenDayEvent; +import com.eu.habbo.messages.incoming.earnings.*; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorRequestBlockedTilesEvent; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorRequestDoorSettingsEvent; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorSaveEvent; @@ -130,6 +131,7 @@ public class PacketManager { this.registerCrafting(); this.registerCamera(); this.registerGameCenter(); + this.registerEarnings(); } public PacketNames getNames() { @@ -766,4 +768,10 @@ public class PacketManager { this.registerHandler(Incoming.SoundboardPlayEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardPlayEvent.class); this.registerHandler(Incoming.SoundboardSetEnabledEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardSetEnabledEvent.class); } + + void registerEarnings() throws Exception { + this.registerHandler(Incoming.RequestEarningsCenterEvent, RequestEarningsCenterEvent.class); + this.registerHandler(Incoming.ClaimEarningsRewardEvent, ClaimEarningsRewardEvent.class); + this.registerHandler(Incoming.ClaimAllEarningsRewardsEvent, ClaimAllEarningsRewardsEvent.class); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 3a0bdf1a..cd8ab2c6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -501,6 +501,9 @@ public class Incoming { public static final int WheelAdminSavePrizesEvent = 9305; public static final int SoundboardPlayEvent = 9306; public static final int SoundboardSetEnabledEvent = 9307; + public static final int RequestEarningsCenterEvent = 9308; + public static final int ClaimEarningsRewardEvent = 9309; + public static final int ClaimAllEarningsRewardsEvent = 9310; public static final int RequestMentionsEvent = 4803; public static final int MarkMentionsReadEvent = 4804; public static final int DeleteMentionEvent = 4805; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java new file mode 100644 index 00000000..c83d0d69 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java @@ -0,0 +1,18 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsClaimResultComposer; + +public class ClaimAllEarningsRewardsEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 3000; + } + + @Override + public void handle() { + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsClaimResultComposer(manager.claimAll(this.client.getHabbo()))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java new file mode 100644 index 00000000..f11d8d86 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java @@ -0,0 +1,19 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsClaimResultComposer; + +public class ClaimEarningsRewardEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1000; + } + + @Override + public void handle() { + String categoryKey = this.packet.readString(); + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsClaimResultComposer(manager.claim(this.client.getHabbo(), categoryKey))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java new file mode 100644 index 00000000..b138a96e --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java @@ -0,0 +1,18 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsCenterComposer; + +public class RequestEarningsCenterEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 500; + } + + @Override + public void handle() { + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsCenterComposer(manager.getEntries(this.client.getHabbo()))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index c52ef526..6d67512d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -607,6 +607,8 @@ public class Outgoing { public static final int WheelAdminPrizesComposer = 9404; public static final int SoundboardSettingsComposer = 9405; public static final int SoundboardPlayComposer = 9406; + public static final int EarningsCenterComposer = 9407; + public static final int EarningsClaimResultComposer = 9408; public static final int MentionReceivedComposer = 4801; public static final int MentionsListComposer = 4802; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java new file mode 100644 index 00000000..9330cecf --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java @@ -0,0 +1,44 @@ +package com.eu.habbo.messages.outgoing.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsEntry; +import com.eu.habbo.habbohotel.earnings.EarningsReward; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class EarningsCenterComposer extends MessageComposer { + private final List entries; + + public EarningsCenterComposer(List entries) { + this.entries = entries; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.EarningsCenterComposer); + this.response.appendInt(this.entries.size()); + + for (EarningsEntry entry : this.entries) { + serializeEntry(entry); + } + + return this.response; + } + + private void serializeEntry(EarningsEntry entry) { + this.response.appendString(entry.getCategory().getKey()); + this.response.appendBoolean(entry.isEnabled()); + this.response.appendBoolean(entry.isClaimable()); + this.response.appendInt(entry.getNextClaimAt()); + this.response.appendInt(entry.getRewards().size()); + + for (EarningsReward reward : entry.getRewards()) { + this.response.appendString(reward.getType()); + this.response.appendInt(reward.getAmount()); + this.response.appendInt(reward.getPointsType()); + this.response.appendString(reward.getData()); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java new file mode 100644 index 00000000..8b807896 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java @@ -0,0 +1,57 @@ +package com.eu.habbo.messages.outgoing.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsClaimResult; +import com.eu.habbo.habbohotel.earnings.EarningsEntry; +import com.eu.habbo.habbohotel.earnings.EarningsReward; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class EarningsClaimResultComposer extends MessageComposer { + private final List results; + + public EarningsClaimResultComposer(EarningsClaimResult result) { + this.results = List.of(result); + } + + public EarningsClaimResultComposer(List results) { + this.results = results; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.EarningsClaimResultComposer); + this.response.appendInt(this.results.size()); + + for (EarningsClaimResult result : this.results) { + this.response.appendString(result.getCategoryKey()); + this.response.appendString(result.getStatus().name().toLowerCase()); + this.response.appendBoolean(result.isSuccess()); + serializeEntry(result.getEntry()); + } + + return this.response; + } + + private void serializeEntry(EarningsEntry entry) { + this.response.appendBoolean(entry != null); + if (entry == null) { + return; + } + + this.response.appendString(entry.getCategory().getKey()); + this.response.appendBoolean(entry.isEnabled()); + this.response.appendBoolean(entry.isClaimable()); + this.response.appendInt(entry.getNextClaimAt()); + this.response.appendInt(entry.getRewards().size()); + + for (EarningsReward reward : entry.getRewards()) { + this.response.appendString(reward.getType()); + this.response.appendInt(reward.getAmount()); + this.response.appendInt(reward.getPointsType()); + this.response.appendString(reward.getData()); + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java new file mode 100644 index 00000000..cf70814e --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java @@ -0,0 +1,202 @@ +package com.eu.habbo.habbohotel.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ClaimRepository; +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ConfigSource; +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.RewardApplier; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EarningsCenterManagerTest { + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.ofEpochSecond(1_800_000_000L), ZoneOffset.UTC); + + @Test + void disabledFeatureReturnsDisabledEntriesAndRejectsClaims() { + TestConfig config = new TestConfig().with("earnings.enabled", "0"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + List entries = manager.getEntries(null); + EarningsClaimResult result = manager.claim(null, "daily_gift"); + + assertFalse(entries.getFirst().isEnabled()); + assertFalse(entries.getFirst().isClaimable()); + assertEquals(EarningsClaimResult.Status.DISABLED, result.getStatus()); + assertTrue(rewards.granted.isEmpty()); + } + + @Test + void unknownCategoryIsRejected() { + EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsClaimResult result = manager.claim(null, "not_real"); + + assertEquals(EarningsClaimResult.Status.UNKNOWN_CATEGORY, result.getStatus()); + } + + @Test + void successfulClaimGrantsConfiguredRewardOnce() { + TestConfig config = enabledConfig() + .with("earnings.daily_gift.credits", "25") + .with("earnings.daily_gift.points", "3") + .with("earnings.daily_gift.points.type", "7"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + EarningsClaimResult first = manager.claim(null, "daily_gift"); + EarningsClaimResult duplicate = manager.claim(null, "daily_gift"); + + assertEquals(EarningsClaimResult.Status.SUCCESS, first.getStatus()); + assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, duplicate.getStatus()); + assertEquals(2, rewards.granted.size()); + assertEquals(EarningsReward.TYPE_CREDITS, rewards.granted.get(0).getType()); + assertEquals(25, rewards.granted.get(0).getAmount()); + assertEquals(EarningsReward.TYPE_POINTS, rewards.granted.get(1).getType()); + assertEquals(7, rewards.granted.get(1).getPointsType()); + } + + @Test + void categoryWithNoConfiguredRewardIsNotClaimable() { + EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsClaimResult result = manager.claim(null, "games"); + + assertEquals(EarningsClaimResult.Status.NO_REWARD, result.getStatus()); + assertFalse(result.getEntry().isClaimable()); + } + + @Test + void configurableBadgeItemAndHcRewardsAreIncludedInEntryState() { + TestConfig config = enabledConfig() + .with("earnings.bonus_bag.badge", "ACH_Test1") + .with("earnings.bonus_bag.item_id", "123") + .with("earnings.bonus_bag.item.quantity", "2") + .with("earnings.bonus_bag.hc.days", "7"); + EarningsCenterManager manager = new EarningsCenterManager(config, new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsEntry entry = manager.getEntries(null).stream() + .filter(current -> current.getCategory() == EarningsCategory.BONUS_BAG) + .findFirst() + .orElseThrow(); + + assertTrue(entry.isClaimable()); + assertEquals(3, entry.getRewards().size()); + assertEquals(EarningsReward.TYPE_BADGE, entry.getRewards().get(0).getType()); + assertEquals("ACH_Test1", entry.getRewards().get(0).getData()); + assertEquals(EarningsReward.TYPE_ITEM, entry.getRewards().get(1).getType()); + assertEquals("123", entry.getRewards().get(1).getData()); + assertEquals(2, entry.getRewards().get(1).getAmount()); + assertEquals(EarningsReward.TYPE_HC_DAYS, entry.getRewards().get(2).getType()); + assertEquals(7, entry.getRewards().get(2).getAmount()); + } + + @Test + void failedRewardGrantRollsBackClaimRecord() { + TestConfig config = enabledConfig().with("earnings.daily_gift.credits", "10"); + TestClaims claims = new TestClaims(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, (habbo, rewards) -> { + throw new SQLException("grant failed"); + }, FIXED_CLOCK); + + EarningsClaimResult failed = manager.claim(null, "daily_gift"); + EarningsClaimResult retried = new EarningsCenterManager(config, claims, new TestRewards(), FIXED_CLOCK) + .claim(null, "daily_gift"); + + assertEquals(EarningsClaimResult.Status.ERROR, failed.getStatus()); + assertEquals(EarningsClaimResult.Status.SUCCESS, retried.getStatus()); + } + + @Test + void claimAllGrantsClaimableRowsAndSkipsAlreadyClaimedRows() throws SQLException { + TestConfig config = enabledConfig() + .with("earnings.daily_gift.credits", "10") + .with("earnings.games.pixels", "4"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + claims.recordClaim(0, "daily_gift", String.valueOf(1_800_000_000L / 86400), 1_800_000_000); + List results = manager.claimAll(null); + + assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, results.get(0).getStatus()); + assertEquals(EarningsClaimResult.Status.SUCCESS, results.get(1).getStatus()); + assertEquals(1, rewards.granted.size()); + assertEquals(EarningsReward.TYPE_PIXELS, rewards.granted.getFirst().getType()); + assertEquals(4, rewards.granted.getFirst().getAmount()); + } + + private static TestConfig enabledConfig() { + return new TestConfig().with("earnings.enabled", "1"); + } + + private static class TestConfig implements ConfigSource { + private final Map values = new HashMap<>(); + + TestConfig with(String key, String value) { + this.values.put(key, value); + return this; + } + + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return this.values.getOrDefault(key, defaultValue ? "1" : "0").equals("1"); + } + + @Override + public int getInt(String key, int defaultValue) { + return Integer.parseInt(this.values.getOrDefault(key, String.valueOf(defaultValue))); + } + + @Override + public String getValue(String key, String defaultValue) { + return this.values.getOrDefault(key, defaultValue); + } + } + + private static class TestClaims implements ClaimRepository { + private final Set claims = new HashSet<>(); + + @Override + public boolean hasClaim(int userId, String category, String periodKey) { + return this.claims.contains(key(userId, category, periodKey)); + } + + @Override + public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) { + return this.claims.add(key(userId, category, periodKey)); + } + + @Override + public void removeClaim(int userId, String category, String periodKey) { + this.claims.remove(key(userId, category, periodKey)); + } + + private String key(int userId, String category, String periodKey) { + return userId + ":" + category + ":" + periodKey; + } + } + + private static class TestRewards implements RewardApplier { + private final List granted = new ArrayList<>(); + + @Override + public void grant(com.eu.habbo.habbohotel.users.Habbo habbo, List rewards) { + this.granted.addAll(rewards); + } + } +} diff --git a/docs/superpowers/plans/2026-06-15-earnings-center.md b/docs/superpowers/plans/2026-06-15-earnings-center.md index 636c2998..9df4750b 100644 --- a/docs/superpowers/plans/2026-06-15-earnings-center.md +++ b/docs/superpowers/plans/2026-06-15-earnings-center.md @@ -37,6 +37,8 @@ - [ ] 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 @@ -71,6 +73,8 @@ - [ ] 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`. diff --git a/docs/superpowers/specs/2026-06-15-earnings-center-design.md b/docs/superpowers/specs/2026-06-15-earnings-center-design.md index 1f758680..4bc1720f 100644 --- a/docs/superpowers/specs/2026-06-15-earnings-center-design.md +++ b/docs/superpowers/specs/2026-06-15-earnings-center-design.md @@ -21,6 +21,8 @@ The first emulator version exposes ten earnings categories: 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: @@ -57,6 +59,10 @@ Add emulator settings with safe defaults: - `earnings..pixels=0` - `earnings..points=0` - `earnings..points.type=5` +- `earnings..badge=` +- `earnings..item_id=0` +- `earnings..item.quantity=1` +- `earnings..hc.days=0` The feature defaults off so existing hotels do not receive surprise economy changes after deploying the jar. @@ -80,7 +86,8 @@ Composer format is intentionally simple and renderer-friendly: category key, ena - 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. +- 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. @@ -93,5 +100,7 @@ Add unit tests around the manager-level logic: - 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.