Merge pull request #210 from simoleo89/feat/earnings-center

feat: add emulator earnings center
This commit is contained in:
DuckieTM
2026-06-15 22:16:09 +02:00
committed by GitHub
19 changed files with 1655 additions and 0 deletions
@@ -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,42 @@ public final class Emulator {
return gameServer;
}
private static void registerEarningsSettings() {
Emulator.config.register("earnings.enabled", "0");
String[] categories = {
"daily_gift",
"games",
"achievements",
"marketplace",
"hc_payday",
"level_progress",
"donations",
"bonus_bag",
"mystery_boxes",
"club_job"
};
for (String category : categories) {
String prefix = "earnings." + category + ".";
Emulator.config.register(prefix + "enabled", "1");
Emulator.config.register(prefix + "cooldown.seconds", "86400");
Emulator.config.register(prefix + "credits", "0");
Emulator.config.register(prefix + "pixels", "0");
Emulator.config.register(prefix + "points", "0");
Emulator.config.register(prefix + "points.type", "5");
Emulator.config.register(prefix + "badge", "");
Emulator.config.register(prefix + "item_id", "0");
Emulator.config.register(prefix + "item.quantity", "1");
Emulator.config.register(prefix + "hc.days", "0");
Emulator.config.register(prefix + "native.enabled", (category.equals("marketplace") || category.equals("hc_payday") || category.equals("achievements") || category.equals("level_progress")) ? "1" : "0");
}
Emulator.config.register("earnings.achievements.min_score", "1");
Emulator.config.register("earnings.achievements.score.step", "100");
Emulator.config.register("earnings.level_progress.min_level", "1");
}
public static RCONServer getRconServer() {
return rconServer;
}
@@ -0,0 +1,38 @@
package com.eu.habbo.habbohotel.earnings;
import java.util.Arrays;
import java.util.Optional;
public enum EarningsCategory {
DAILY_GIFT("daily_gift"),
GAMES("games"),
ACHIEVEMENTS("achievements"),
MARKETPLACE("marketplace"),
HC_PAYDAY("hc_payday"),
LEVEL_PROGRESS("level_progress"),
DONATIONS("donations"),
BONUS_BAG("bonus_bag"),
MYSTERY_BOXES("mystery_boxes"),
CLUB_JOB("club_job");
private final String key;
EarningsCategory(String key) {
this.key = key;
}
public String getKey() {
return key;
}
public static Optional<EarningsCategory> fromKey(String key) {
if (key == null || key.isBlank()) {
return Optional.empty();
}
String normalized = key.trim().toLowerCase();
return Arrays.stream(values())
.filter(category -> category.key.equals(normalized))
.findFirst();
}
}
@@ -0,0 +1,570 @@
package com.eu.habbo.habbohotel.earnings;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.catalog.marketplace.MarketPlace;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboBadge;
import com.eu.habbo.habbohotel.users.subscriptions.SubscriptionHabboClub;
import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLIntegrityConstraintViolationException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class EarningsCenterManager {
public static final String CONFIG_PREFIX = "earnings.";
private static final int DEFAULT_COOLDOWN_SECONDS = 86400;
private static final int DEFAULT_POINTS_TYPE = 5;
private static final int MAX_CONFIGURED_REWARD = 1_000_000;
private static final int MAX_ITEM_QUANTITY = 100;
private static final int MAX_HC_DAYS = 365;
private final ConfigSource config;
private final ClaimRepository claims;
private final RewardApplier rewards;
private final NativeIntegration nativeIntegration;
private final Clock clock;
public EarningsCenterManager() {
this(new EmulatorConfigSource(), new JdbcClaimRepository(), new HabboRewardApplier(), new DefaultNativeIntegration(), Clock.systemUTC());
}
public EarningsCenterManager(ConfigSource config, ClaimRepository claims, RewardApplier rewards, Clock clock) {
this(config, claims, rewards, new NoopNativeIntegration(), clock);
}
public EarningsCenterManager(ConfigSource config, ClaimRepository claims, RewardApplier rewards, NativeIntegration nativeIntegration, Clock clock) {
this.config = config;
this.claims = claims;
this.rewards = rewards;
this.nativeIntegration = nativeIntegration;
this.clock = clock;
}
public List<EarningsEntry> getEntries(Habbo habbo) {
int userId = getUserId(habbo);
int now = now();
List<EarningsEntry> entries = new ArrayList<>();
for (EarningsCategory category : EarningsCategory.values()) {
entries.add(buildEntry(habbo, userId, category, now));
}
return entries;
}
public EarningsClaimResult claim(Habbo habbo, String categoryKey) {
Optional<EarningsCategory> requestedCategory = EarningsCategory.fromKey(categoryKey);
if (requestedCategory.isEmpty()) {
return new EarningsClaimResult(null, EarningsClaimResult.Status.UNKNOWN_CATEGORY, null);
}
return claim(habbo, requestedCategory.get());
}
public List<EarningsClaimResult> claimAll(Habbo habbo) {
List<EarningsClaimResult> results = new ArrayList<>();
for (EarningsCategory category : EarningsCategory.values()) {
results.add(claim(habbo, category));
}
return results;
}
private EarningsClaimResult claim(Habbo habbo, EarningsCategory category) {
int userId = getUserId(habbo);
int now = now();
CategoryDefinition definition = loadDefinition(habbo, category);
if (!definition.enabled()) {
return new EarningsClaimResult(category, EarningsClaimResult.Status.DISABLED, buildEntry(habbo, userId, category, now));
}
if (this.nativeIntegration.handles(category) && nativeEnabled(category)) {
return claimNative(habbo, userId, category, now, definition);
}
if (definition.rewards().isEmpty()) {
return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now));
}
if (!isEligibleForProgressClaim(habbo, category)) {
return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now));
}
String periodKey = periodKey(habbo, category, now, definition.cooldownSeconds());
try {
if (!this.claims.recordClaim(userId, category.getKey(), periodKey, now)) {
return new EarningsClaimResult(category, EarningsClaimResult.Status.ALREADY_CLAIMED, buildEntry(habbo, userId, category, now));
}
this.rewards.grant(habbo, definition.rewards());
return new EarningsClaimResult(category, EarningsClaimResult.Status.SUCCESS, buildEntry(habbo, userId, category, now));
} catch (SQLException e) {
try {
this.claims.removeClaim(userId, category.getKey(), periodKey);
} catch (SQLException ignored) {
}
return new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now));
}
}
private EarningsClaimResult claimNative(Habbo habbo, int userId, EarningsCategory category, int now, CategoryDefinition definition) {
try {
if (definition.rewards().isEmpty() || !this.nativeIntegration.hasClaim(habbo, category)) {
return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now));
}
return this.nativeIntegration.claim(habbo, category)
? new EarningsClaimResult(category, EarningsClaimResult.Status.SUCCESS, buildEntry(habbo, userId, category, now))
: new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now));
} catch (SQLException e) {
return new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now));
}
}
private EarningsEntry buildEntry(Habbo habbo, int userId, EarningsCategory category, int now) {
CategoryDefinition definition = loadDefinition(habbo, category);
boolean claimable = false;
int nextClaimAt = 0;
if (definition.enabled() && !definition.rewards().isEmpty()) {
if (this.nativeIntegration.handles(category) && nativeEnabled(category)) {
try {
claimable = this.nativeIntegration.hasClaim(habbo, category);
} catch (SQLException e) {
claimable = false;
}
return new EarningsEntry(category, true, claimable, 0, definition.rewards());
}
if (!isEligibleForProgressClaim(habbo, category)) {
return new EarningsEntry(category, true, false, 0, definition.rewards());
}
String periodKey = periodKey(habbo, category, now, definition.cooldownSeconds());
try {
claimable = !this.claims.hasClaim(userId, category.getKey(), periodKey);
nextClaimAt = claimable ? 0 : nextPeriodStart(now, definition.cooldownSeconds());
} catch (SQLException e) {
claimable = false;
nextClaimAt = nextPeriodStart(now, definition.cooldownSeconds());
}
}
return new EarningsEntry(category, definition.enabled(), claimable, nextClaimAt, definition.rewards());
}
private CategoryDefinition loadDefinition(Habbo habbo, EarningsCategory category) {
String key = CONFIG_PREFIX + category.getKey() + ".";
boolean enabled = this.config.getBoolean(CONFIG_PREFIX + "enabled", false)
&& this.config.getBoolean(key + "enabled", true);
int cooldown = Math.max(60, this.config.getInt(key + "cooldown.seconds", DEFAULT_COOLDOWN_SECONDS));
int pointsType = Math.max(0, this.config.getInt(key + "points.type", DEFAULT_POINTS_TYPE));
List<EarningsReward> rewards = new ArrayList<>();
if (nativeEnabled(category) && this.nativeIntegration.handles(category)) {
try {
rewards.addAll(this.nativeIntegration.rewards(habbo, category));
} catch (SQLException ignored) {
}
} else {
addReward(rewards, EarningsReward.TYPE_CREDITS, this.config.getInt(key + "credits", 0), 0);
addReward(rewards, EarningsReward.TYPE_PIXELS, this.config.getInt(key + "pixels", 0), 0);
addReward(rewards, EarningsReward.TYPE_POINTS, this.config.getInt(key + "points", 0), pointsType);
addBadgeReward(rewards, this.config.getValue(key + "badge", ""));
addItemReward(rewards, this.config.getInt(key + "item_id", 0), this.config.getInt(key + "item.quantity", 1));
addHcReward(rewards, this.config.getInt(key + "hc.days", 0));
}
return new CategoryDefinition(enabled, cooldown, rewards);
}
private boolean nativeEnabled(EarningsCategory category) {
return this.config.getBoolean(CONFIG_PREFIX + category.getKey() + ".native.enabled", true);
}
private boolean isEligibleForProgressClaim(Habbo habbo, EarningsCategory category) {
if (!nativeEnabled(category) || habbo == null || habbo.getHabboStats() == null) {
return true;
}
if (category == EarningsCategory.ACHIEVEMENTS) {
int minimumScore = Math.max(1, this.config.getInt(CONFIG_PREFIX + category.getKey() + ".min_score", 1));
return habbo.getHabboStats().getAchievementScore() >= minimumScore;
}
if (category == EarningsCategory.LEVEL_PROGRESS) {
int minimumLevel = Math.max(0, this.config.getInt(CONFIG_PREFIX + category.getKey() + ".min_level", 1));
int highestLevel = Math.max(habbo.getHabboStats().citizenshipLevel, habbo.getHabboStats().helpersLevel);
return highestLevel >= minimumLevel;
}
return true;
}
private void addReward(List<EarningsReward> rewards, String type, int amount, int pointsType) {
int clampedAmount = Math.min(Math.max(0, amount), MAX_CONFIGURED_REWARD);
if (clampedAmount > 0) {
rewards.add(new EarningsReward(type, clampedAmount, pointsType));
}
}
private void addBadgeReward(List<EarningsReward> rewards, String badgeCode) {
if (badgeCode == null || !badgeCode.matches("[A-Za-z0-9_\\-]{1,64}")) {
return;
}
rewards.add(new EarningsReward(EarningsReward.TYPE_BADGE, 1, 0, badgeCode));
}
private void addItemReward(List<EarningsReward> rewards, int itemId, int quantity) {
if (itemId <= 0 || quantity <= 0) {
return;
}
rewards.add(new EarningsReward(EarningsReward.TYPE_ITEM, Math.min(quantity, MAX_ITEM_QUANTITY), 0, String.valueOf(itemId)));
}
private void addHcReward(List<EarningsReward> rewards, int days) {
if (days <= 0) {
return;
}
rewards.add(new EarningsReward(EarningsReward.TYPE_HC_DAYS, Math.min(days, MAX_HC_DAYS), 0));
}
private int getUserId(Habbo habbo) {
if (habbo == null || habbo.getHabboInfo() == null) {
return 0;
}
return habbo.getHabboInfo().getId();
}
private int now() {
return (int) (this.clock.instant().getEpochSecond());
}
private String periodKey(Habbo habbo, EarningsCategory category, int now, int cooldownSeconds) {
if (nativeEnabled(category) && habbo != null && habbo.getHabboStats() != null) {
if (category == EarningsCategory.ACHIEVEMENTS) {
int scoreStep = Math.max(1, this.config.getInt(CONFIG_PREFIX + category.getKey() + ".score.step", 100));
return "score:" + (habbo.getHabboStats().getAchievementScore() / scoreStep);
}
if (category == EarningsCategory.LEVEL_PROGRESS) {
int highestLevel = Math.max(habbo.getHabboStats().citizenshipLevel, habbo.getHabboStats().helpersLevel);
return "level:" + highestLevel;
}
}
return String.valueOf(now / cooldownSeconds);
}
private int nextPeriodStart(int now, int cooldownSeconds) {
return ((now / cooldownSeconds) + 1) * cooldownSeconds;
}
private record CategoryDefinition(boolean enabled, int cooldownSeconds, List<EarningsReward> rewards) {
}
public interface ConfigSource {
boolean getBoolean(String key, boolean defaultValue);
int getInt(String key, int defaultValue);
String getValue(String key, String defaultValue);
}
public interface ClaimRepository {
boolean hasClaim(int userId, String category, String periodKey) throws SQLException;
boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException;
void removeClaim(int userId, String category, String periodKey) throws SQLException;
}
public interface RewardApplier {
void grant(Habbo habbo, List<EarningsReward> rewards) throws SQLException;
}
public interface NativeIntegration {
boolean handles(EarningsCategory category);
boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException;
List<EarningsReward> rewards(Habbo habbo, EarningsCategory category) throws SQLException;
boolean claim(Habbo habbo, EarningsCategory category) throws SQLException;
}
private static class EmulatorConfigSource implements ConfigSource {
@Override
public boolean getBoolean(String key, boolean defaultValue) {
return Emulator.getConfig().getBoolean(key, defaultValue);
}
@Override
public int getInt(String key, int defaultValue) {
return Emulator.getConfig().getInt(key, defaultValue);
}
@Override
public String getValue(String key, String defaultValue) {
return Emulator.getConfig().getValue(key, defaultValue);
}
}
private static class JdbcClaimRepository implements ClaimRepository {
@Override
public boolean hasClaim(int userId, String category, String periodKey) throws SQLException {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT 1 FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) {
statement.setInt(1, userId);
statement.setString(2, category);
statement.setString(3, periodKey);
return statement.executeQuery().next();
}
}
@Override
public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO users_earnings_claims (user_id, category, period_key, claimed_at) VALUES (?, ?, ?, FROM_UNIXTIME(?))")) {
statement.setInt(1, userId);
statement.setString(2, category);
statement.setString(3, periodKey);
statement.setInt(4, claimedAt);
return statement.executeUpdate() == 1;
} catch (SQLIntegrityConstraintViolationException duplicate) {
return false;
}
}
@Override
public void removeClaim(int userId, String category, String periodKey) throws SQLException {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) {
statement.setInt(1, userId);
statement.setString(2, category);
statement.setString(3, periodKey);
statement.executeUpdate();
}
}
}
private static class HabboRewardApplier implements RewardApplier {
@Override
public void grant(Habbo habbo, List<EarningsReward> rewards) throws SQLException {
if (habbo == null) {
return;
}
for (EarningsReward reward : rewards) {
switch (reward.getType()) {
case EarningsReward.TYPE_CREDITS -> habbo.giveCredits(reward.getAmount());
case EarningsReward.TYPE_PIXELS -> habbo.givePixels(reward.getAmount());
case EarningsReward.TYPE_POINTS -> habbo.givePoints(reward.getPointsType(), reward.getAmount());
case EarningsReward.TYPE_BADGE -> grantBadge(habbo, reward.getData());
case EarningsReward.TYPE_ITEM -> grantItem(habbo, Integer.parseInt(reward.getData()), reward.getAmount());
case EarningsReward.TYPE_HC_DAYS -> grantHcDays(habbo, reward.getAmount());
default -> {
}
}
}
}
private void grantBadge(Habbo habbo, String badgeCode) throws SQLException {
if (habbo.getInventory().getBadgesComponent().hasBadge(badgeCode)) {
return;
}
HabboBadge badge = new HabboBadge(0, badgeCode, 0, habbo);
badge.run();
habbo.getInventory().getBadgesComponent().addBadge(badge);
if (habbo.getClient() != null) {
habbo.getClient().sendResponse(new AddUserBadgeComposer(badge));
}
}
private void grantItem(Habbo habbo, int itemId, int quantity) throws SQLException {
if (!itemExists(itemId)) {
throw new SQLException("Unknown earnings item reward " + itemId);
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO items (user_id, item_id, extra_data) VALUES (?, ?, '')")) {
for (int i = 0; i < quantity; i++) {
statement.setInt(1, habbo.getHabboInfo().getId());
statement.setInt(2, itemId);
statement.addBatch();
}
statement.executeBatch();
}
}
private boolean itemExists(int itemId) throws SQLException {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT id FROM items_base WHERE id = ? LIMIT 1")) {
statement.setInt(1, itemId);
try (ResultSet set = statement.executeQuery()) {
return set.next();
}
}
}
private void grantHcDays(Habbo habbo, int days) throws SQLException {
int now = Emulator.getIntUnixTimestamp();
int current = habbo.getHabboStats().getClubExpireTimestamp();
int newExpire = (current > now ? current : now) + (days * 86400);
habbo.getHabboStats().setClubExpireTimestamp(newExpire);
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET club_expire_timestamp = ? WHERE user_id = ? LIMIT 1")) {
statement.setInt(1, newExpire);
statement.setInt(2, habbo.getHabboInfo().getId());
statement.executeUpdate();
}
}
}
private static class NoopNativeIntegration implements NativeIntegration {
@Override
public boolean handles(EarningsCategory category) {
return false;
}
@Override
public boolean hasClaim(Habbo habbo, EarningsCategory category) {
return false;
}
@Override
public List<EarningsReward> rewards(Habbo habbo, EarningsCategory category) {
return List.of();
}
@Override
public boolean claim(Habbo habbo, EarningsCategory category) {
return false;
}
}
private static class DefaultNativeIntegration implements NativeIntegration {
@Override
public boolean handles(EarningsCategory category) {
return category == EarningsCategory.MARKETPLACE || category == EarningsCategory.HC_PAYDAY;
}
@Override
public boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException {
return !rewards(habbo, category).isEmpty();
}
@Override
public List<EarningsReward> rewards(Habbo habbo, EarningsCategory category) throws SQLException {
if (habbo == null) {
return List.of();
}
if (category == EarningsCategory.MARKETPLACE) {
int soldPriceTotal = habbo.getInventory().getSoldPriceTotal();
if (soldPriceTotal <= 0) {
return List.of();
}
if (MarketPlace.MARKETPLACE_CURRENCY == 0) {
return List.of(new EarningsReward(EarningsReward.TYPE_CREDITS, soldPriceTotal, 0));
}
return List.of(new EarningsReward(EarningsReward.TYPE_POINTS, soldPriceTotal, MarketPlace.MARKETPLACE_CURRENCY));
}
if (category == EarningsCategory.HC_PAYDAY) {
return hcPaydayRewards(habbo);
}
return List.of();
}
@Override
public boolean claim(Habbo habbo, EarningsCategory category) throws SQLException {
if (habbo == null || habbo.getClient() == null) {
return false;
}
if (category == EarningsCategory.MARKETPLACE) {
if (habbo.getInventory().getSoldPriceTotal() <= 0) {
return false;
}
MarketPlace.getCredits(habbo.getClient());
return true;
}
if (category == EarningsCategory.HC_PAYDAY) {
if (hcPaydayRewards(habbo).isEmpty()) {
return false;
}
SubscriptionHabboClub.processUnclaimed(habbo);
return true;
}
return false;
}
private List<EarningsReward> hcPaydayRewards(Habbo habbo) throws SQLException {
List<EarningsReward> rewards = new ArrayList<>();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT currency, SUM(total_payout) AS amount FROM logs_hc_payday WHERE user_id = ? AND claimed = 0 GROUP BY currency")) {
statement.setInt(1, habbo.getHabboInfo().getId());
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
EarningsReward reward = currencyReward(set.getString("currency"), set.getInt("amount"));
if (reward != null) {
rewards.add(reward);
}
}
}
}
return rewards;
}
private EarningsReward currencyReward(String currency, int amount) {
if (amount <= 0) {
return null;
}
String normalized = currency == null ? "" : currency.trim().toLowerCase();
return switch (normalized) {
case "credits", "credit", "coins", "coin" -> new EarningsReward(EarningsReward.TYPE_CREDITS, amount, 0);
case "duckets", "ducket", "pixels", "pixel" -> new EarningsReward(EarningsReward.TYPE_PIXELS, amount, 0);
case "diamonds", "diamond" -> new EarningsReward(EarningsReward.TYPE_POINTS, amount, 5);
default -> {
try {
yield new EarningsReward(EarningsReward.TYPE_POINTS, amount, Math.max(0, Integer.parseInt(normalized)));
} catch (NumberFormatException e) {
yield null;
}
}
};
}
}
}
@@ -0,0 +1,42 @@
package com.eu.habbo.habbohotel.earnings;
public class EarningsClaimResult {
public enum Status {
SUCCESS,
DISABLED,
UNKNOWN_CATEGORY,
ALREADY_CLAIMED,
NO_REWARD,
ERROR
}
private final EarningsCategory category;
private final Status status;
private final EarningsEntry entry;
public EarningsClaimResult(EarningsCategory category, Status status, EarningsEntry entry) {
this.category = category;
this.status = status;
this.entry = entry;
}
public EarningsCategory getCategory() {
return category;
}
public String getCategoryKey() {
return category == null ? "" : category.getKey();
}
public Status getStatus() {
return status;
}
public boolean isSuccess() {
return status == Status.SUCCESS;
}
public EarningsEntry getEntry() {
return entry;
}
}
@@ -0,0 +1,39 @@
package com.eu.habbo.habbohotel.earnings;
import java.util.List;
public class EarningsEntry {
private final EarningsCategory category;
private final boolean enabled;
private final boolean claimable;
private final int nextClaimAt;
private final List<EarningsReward> rewards;
public EarningsEntry(EarningsCategory category, boolean enabled, boolean claimable, int nextClaimAt, List<EarningsReward> rewards) {
this.category = category;
this.enabled = enabled;
this.claimable = claimable;
this.nextClaimAt = Math.max(0, nextClaimAt);
this.rewards = List.copyOf(rewards);
}
public EarningsCategory getCategory() {
return category;
}
public boolean isEnabled() {
return enabled;
}
public boolean isClaimable() {
return claimable;
}
public int getNextClaimAt() {
return nextClaimAt;
}
public List<EarningsReward> getRewards() {
return rewards;
}
}
@@ -0,0 +1,42 @@
package com.eu.habbo.habbohotel.earnings;
public class EarningsReward {
public static final String TYPE_CREDITS = "credits";
public static final String TYPE_PIXELS = "pixels";
public static final String TYPE_POINTS = "points";
public static final String TYPE_BADGE = "badge";
public static final String TYPE_ITEM = "item";
public static final String TYPE_HC_DAYS = "hc_days";
private final String type;
private final int amount;
private final int pointsType;
private final String data;
public EarningsReward(String type, int amount, int pointsType) {
this(type, amount, pointsType, "");
}
public EarningsReward(String type, int amount, int pointsType, String data) {
this.type = type;
this.amount = Math.max(0, amount);
this.pointsType = Math.max(0, pointsType);
this.data = data == null ? "" : data;
}
public String getType() {
return type;
}
public int getAmount() {
return amount;
}
public int getPointsType() {
return pointsType;
}
public String getData() {
return data;
}
}
@@ -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,281 @@
package com.eu.habbo.habbohotel.earnings;
import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ClaimRepository;
import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ConfigSource;
import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.RewardApplier;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EarningsCenterManagerTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.ofEpochSecond(1_800_000_000L), ZoneOffset.UTC);
@Test
void disabledFeatureReturnsDisabledEntriesAndRejectsClaims() {
TestConfig config = new TestConfig().with("earnings.enabled", "0");
TestClaims claims = new TestClaims();
TestRewards rewards = new TestRewards();
EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK);
List<EarningsEntry> entries = manager.getEntries(null);
EarningsClaimResult result = manager.claim(null, "daily_gift");
assertFalse(entries.getFirst().isEnabled());
assertFalse(entries.getFirst().isClaimable());
assertEquals(EarningsClaimResult.Status.DISABLED, result.getStatus());
assertTrue(rewards.granted.isEmpty());
}
@Test
void unknownCategoryIsRejected() {
EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK);
EarningsClaimResult result = manager.claim(null, "not_real");
assertEquals(EarningsClaimResult.Status.UNKNOWN_CATEGORY, result.getStatus());
}
@Test
void successfulClaimGrantsConfiguredRewardOnce() {
TestConfig config = enabledConfig()
.with("earnings.daily_gift.credits", "25")
.with("earnings.daily_gift.points", "3")
.with("earnings.daily_gift.points.type", "7");
TestClaims claims = new TestClaims();
TestRewards rewards = new TestRewards();
EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK);
EarningsClaimResult first = manager.claim(null, "daily_gift");
EarningsClaimResult duplicate = manager.claim(null, "daily_gift");
assertEquals(EarningsClaimResult.Status.SUCCESS, first.getStatus());
assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, duplicate.getStatus());
assertEquals(2, rewards.granted.size());
assertEquals(EarningsReward.TYPE_CREDITS, rewards.granted.get(0).getType());
assertEquals(25, rewards.granted.get(0).getAmount());
assertEquals(EarningsReward.TYPE_POINTS, rewards.granted.get(1).getType());
assertEquals(7, rewards.granted.get(1).getPointsType());
}
@Test
void categoryWithNoConfiguredRewardIsNotClaimable() {
EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK);
EarningsClaimResult result = manager.claim(null, "games");
assertEquals(EarningsClaimResult.Status.NO_REWARD, result.getStatus());
assertFalse(result.getEntry().isClaimable());
}
@Test
void configurableBadgeItemAndHcRewardsAreIncludedInEntryState() {
TestConfig config = enabledConfig()
.with("earnings.bonus_bag.badge", "ACH_Test1")
.with("earnings.bonus_bag.item_id", "123")
.with("earnings.bonus_bag.item.quantity", "2")
.with("earnings.bonus_bag.hc.days", "7");
EarningsCenterManager manager = new EarningsCenterManager(config, new TestClaims(), new TestRewards(), FIXED_CLOCK);
EarningsEntry entry = manager.getEntries(null).stream()
.filter(current -> current.getCategory() == EarningsCategory.BONUS_BAG)
.findFirst()
.orElseThrow();
assertTrue(entry.isClaimable());
assertEquals(3, entry.getRewards().size());
assertEquals(EarningsReward.TYPE_BADGE, entry.getRewards().get(0).getType());
assertEquals("ACH_Test1", entry.getRewards().get(0).getData());
assertEquals(EarningsReward.TYPE_ITEM, entry.getRewards().get(1).getType());
assertEquals("123", entry.getRewards().get(1).getData());
assertEquals(2, entry.getRewards().get(1).getAmount());
assertEquals(EarningsReward.TYPE_HC_DAYS, entry.getRewards().get(2).getType());
assertEquals(7, entry.getRewards().get(2).getAmount());
}
@Test
void failedRewardGrantRollsBackClaimRecord() {
TestConfig config = enabledConfig().with("earnings.daily_gift.credits", "10");
TestClaims claims = new TestClaims();
EarningsCenterManager manager = new EarningsCenterManager(config, claims, (habbo, rewards) -> {
throw new SQLException("grant failed");
}, FIXED_CLOCK);
EarningsClaimResult failed = manager.claim(null, "daily_gift");
EarningsClaimResult retried = new EarningsCenterManager(config, claims, new TestRewards(), FIXED_CLOCK)
.claim(null, "daily_gift");
assertEquals(EarningsClaimResult.Status.ERROR, failed.getStatus());
assertEquals(EarningsClaimResult.Status.SUCCESS, retried.getStatus());
}
@Test
void nativeMarketplaceRowsUseNativeClaimInsteadOfPeriodicClaimLedger() {
TestConfig config = enabledConfig().with("earnings.marketplace.native.enabled", "1");
TestClaims claims = new TestClaims();
TestNativeIntegration nativeIntegration = new TestNativeIntegration(EarningsCategory.MARKETPLACE)
.withReward(new EarningsReward(EarningsReward.TYPE_CREDITS, 45, 0));
EarningsCenterManager manager = new EarningsCenterManager(config, claims, new TestRewards(), nativeIntegration, FIXED_CLOCK);
EarningsEntry entry = manager.getEntries(null).stream()
.filter(current -> current.getCategory() == EarningsCategory.MARKETPLACE)
.findFirst()
.orElseThrow();
EarningsClaimResult result = manager.claim(null, "marketplace");
assertTrue(entry.isClaimable());
assertEquals(45, entry.getRewards().getFirst().getAmount());
assertEquals(EarningsClaimResult.Status.SUCCESS, result.getStatus());
assertEquals(1, nativeIntegration.claims);
assertTrue(claims.claims.isEmpty());
}
@Test
void nativeRowsWithoutAvailableRewardsAreNotClaimable() {
TestConfig config = enabledConfig().with("earnings.hc_payday.native.enabled", "1");
TestNativeIntegration nativeIntegration = new TestNativeIntegration(EarningsCategory.HC_PAYDAY);
EarningsCenterManager manager = new EarningsCenterManager(config, new TestClaims(), new TestRewards(), nativeIntegration, FIXED_CLOCK);
EarningsEntry entry = manager.getEntries(null).stream()
.filter(current -> current.getCategory() == EarningsCategory.HC_PAYDAY)
.findFirst()
.orElseThrow();
EarningsClaimResult result = manager.claim(null, "hc_payday");
assertFalse(entry.isClaimable());
assertEquals(EarningsClaimResult.Status.NO_REWARD, result.getStatus());
assertEquals(0, nativeIntegration.claims);
}
@Test
void claimAllGrantsClaimableRowsAndSkipsAlreadyClaimedRows() throws SQLException {
TestConfig config = enabledConfig()
.with("earnings.daily_gift.credits", "10")
.with("earnings.games.pixels", "4");
TestClaims claims = new TestClaims();
TestRewards rewards = new TestRewards();
EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK);
claims.recordClaim(0, "daily_gift", String.valueOf(1_800_000_000L / 86400), 1_800_000_000);
List<EarningsClaimResult> results = manager.claimAll(null);
assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, results.get(0).getStatus());
assertEquals(EarningsClaimResult.Status.SUCCESS, results.get(1).getStatus());
assertEquals(1, rewards.granted.size());
assertEquals(EarningsReward.TYPE_PIXELS, rewards.granted.getFirst().getType());
assertEquals(4, rewards.granted.getFirst().getAmount());
}
private static TestConfig enabledConfig() {
return new TestConfig().with("earnings.enabled", "1");
}
private static class TestConfig implements ConfigSource {
private final Map<String, String> values = new HashMap<>();
TestConfig with(String key, String value) {
this.values.put(key, value);
return this;
}
@Override
public boolean getBoolean(String key, boolean defaultValue) {
return this.values.getOrDefault(key, defaultValue ? "1" : "0").equals("1");
}
@Override
public int getInt(String key, int defaultValue) {
return Integer.parseInt(this.values.getOrDefault(key, String.valueOf(defaultValue)));
}
@Override
public String getValue(String key, String defaultValue) {
return this.values.getOrDefault(key, defaultValue);
}
}
private static class TestClaims implements ClaimRepository {
private final Set<String> claims = new HashSet<>();
@Override
public boolean hasClaim(int userId, String category, String periodKey) {
return this.claims.contains(key(userId, category, periodKey));
}
@Override
public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) {
return this.claims.add(key(userId, category, periodKey));
}
@Override
public void removeClaim(int userId, String category, String periodKey) {
this.claims.remove(key(userId, category, periodKey));
}
private String key(int userId, String category, String periodKey) {
return userId + ":" + category + ":" + periodKey;
}
}
private static class TestRewards implements RewardApplier {
private final List<EarningsReward> granted = new ArrayList<>();
@Override
public void grant(com.eu.habbo.habbohotel.users.Habbo habbo, List<EarningsReward> rewards) {
this.granted.addAll(rewards);
}
}
private static class TestNativeIntegration implements EarningsCenterManager.NativeIntegration {
private final EarningsCategory category;
private final List<EarningsReward> rewards = new ArrayList<>();
private int claims = 0;
private TestNativeIntegration(EarningsCategory category) {
this.category = category;
}
private TestNativeIntegration withReward(EarningsReward reward) {
this.rewards.add(reward);
return this;
}
@Override
public boolean handles(EarningsCategory category) {
return this.category == category;
}
@Override
public boolean hasClaim(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) {
return handles(category) && !this.rewards.isEmpty();
}
@Override
public List<EarningsReward> rewards(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) {
return handles(category) ? List.copyOf(this.rewards) : List.of();
}
@Override
public boolean claim(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) {
if (!hasClaim(habbo, category)) {
return false;
}
this.claims++;
this.rewards.clear();
return true;
}
}
}