feat(earnings): add emulator rewards center

This commit is contained in:
simoleo89
2026-06-15 20:41:00 +02:00
parent dac83e8a62
commit e29e06201c
18 changed files with 1046 additions and 1 deletions
+115
View File
@@ -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.');
@@ -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;
}
@@ -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,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<EarningsEntry> getEntries(Habbo habbo) {
int userId = getUserId(habbo);
int now = now();
List<EarningsEntry> entries = new ArrayList<>();
for (EarningsCategory category : EarningsCategory.values()) {
entries.add(buildEntry(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(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<EarningsReward> 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<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(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<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;
}
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();
}
}
}
}
@@ -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;
}
}
@@ -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())));
}
}
@@ -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,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<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 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);
}
}
}
@@ -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`.
@@ -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.<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`
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.