diff --git a/Database Updates/012_earnings_center.sql b/Database Updates/012_earnings_center.sql index d20f5afa..f3063162 100644 --- a/Database Updates/012_earnings_center.sql +++ b/Database Updates/012_earnings_center.sql @@ -113,3 +113,15 @@ INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES ('earnings.club_job.item_id', '0', 'Items base id granted by club and job earnings claims.'), ('earnings.club_job.item.quantity', '1', 'Furni quantity granted by club and job earnings claims.'), ('earnings.club_job.hc.days', '0', 'HC days granted by club and job earnings claims.'); + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.daily_gift.native.enabled', '0', 'Use native hotel subsystem data for daily gift earnings claims when available.'), +('earnings.games.native.enabled', '0', 'Use native hotel subsystem data for games earnings claims when available.'), +('earnings.achievements.native.enabled', '0', 'Use native hotel subsystem data for achievements earnings claims when available.'), +('earnings.marketplace.native.enabled', '1', 'Use marketplace sold item payouts for marketplace earnings claims.'), +('earnings.hc_payday.native.enabled', '1', 'Use unclaimed HC payday logs for HC payday earnings claims.'), +('earnings.level_progress.native.enabled', '0', 'Use native hotel subsystem data for level progress earnings claims when available.'), +('earnings.donations.native.enabled', '0', 'Use native hotel subsystem data for donations earnings claims when available.'), +('earnings.bonus_bag.native.enabled', '0', 'Use native hotel subsystem data for bonus bag earnings claims when available.'), +('earnings.mystery_boxes.native.enabled', '0', 'Use native hotel subsystem data for mystery boxes earnings claims when available.'), +('earnings.club_job.native.enabled', '0', 'Use native hotel subsystem data for club and job earnings claims when available.'); diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 8d4a95fe..e7b7ad45 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -517,6 +517,7 @@ public final class Emulator { 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")) ? "1" : "0"); } } 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 index 32514c51..58f589e2 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java @@ -1,8 +1,10 @@ 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; @@ -26,16 +28,22 @@ public class EarningsCenterManager { 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(), Clock.systemUTC()); + 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; } @@ -45,7 +53,7 @@ public class EarningsCenterManager { List entries = new ArrayList<>(); for (EarningsCategory category : EarningsCategory.values()) { - entries.add(buildEntry(userId, category, now)); + entries.add(buildEntry(habbo, userId, category, now)); } return entries; @@ -73,40 +81,68 @@ public class EarningsCenterManager { private EarningsClaimResult claim(Habbo habbo, EarningsCategory category) { int userId = getUserId(habbo); int now = now(); - CategoryDefinition definition = loadDefinition(category); + CategoryDefinition definition = loadDefinition(habbo, category); if (!definition.enabled()) { - return new EarningsClaimResult(category, EarningsClaimResult.Status.DISABLED, buildEntry(userId, category, now)); + 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(userId, category, now)); + return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, 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)); + 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(userId, category, now)); + 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(userId, category, now)); + return new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(habbo, userId, category, now)); } } - private EarningsEntry buildEntry(int userId, EarningsCategory category, int now) { - CategoryDefinition definition = loadDefinition(category); + 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()); + } + String periodKey = periodKey(now, definition.cooldownSeconds()); try { @@ -121,7 +157,7 @@ public class EarningsCenterManager { return new EarningsEntry(category, definition.enabled(), claimable, nextClaimAt, definition.rewards()); } - private CategoryDefinition loadDefinition(EarningsCategory category) { + 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); @@ -129,16 +165,27 @@ public class EarningsCenterManager { 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)); + 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 void addReward(List rewards, String type, int amount, int pointsType) { int clampedAmount = Math.min(Math.max(0, amount), MAX_CONFIGURED_REWARD); if (clampedAmount > 0) { @@ -213,6 +260,16 @@ public class EarningsCenterManager { void grant(Habbo habbo, List rewards) throws SQLException; } + public interface NativeIntegration { + boolean handles(EarningsCategory category); + + boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException; + + List rewards(Habbo habbo, EarningsCategory category) throws SQLException; + + boolean claim(Habbo habbo, EarningsCategory category) throws SQLException; + } + private static class EmulatorConfigSource implements ConfigSource { @Override public boolean getBoolean(String key, boolean defaultValue) { @@ -344,4 +401,131 @@ public class EarningsCenterManager { } } } + + private static class NoopNativeIntegration implements NativeIntegration { + @Override + public boolean handles(EarningsCategory category) { + return false; + } + + @Override + public boolean hasClaim(Habbo habbo, EarningsCategory category) { + return false; + } + + @Override + public List rewards(Habbo habbo, EarningsCategory category) { + return List.of(); + } + + @Override + public boolean claim(Habbo habbo, EarningsCategory category) { + return false; + } + } + + private static class DefaultNativeIntegration implements NativeIntegration { + @Override + public boolean handles(EarningsCategory category) { + return category == EarningsCategory.MARKETPLACE || category == EarningsCategory.HC_PAYDAY; + } + + @Override + public boolean hasClaim(Habbo habbo, EarningsCategory category) throws SQLException { + return !rewards(habbo, category).isEmpty(); + } + + @Override + public List rewards(Habbo habbo, EarningsCategory category) throws SQLException { + if (habbo == null) { + return List.of(); + } + + if (category == EarningsCategory.MARKETPLACE) { + int soldPriceTotal = habbo.getInventory().getSoldPriceTotal(); + if (soldPriceTotal <= 0) { + return List.of(); + } + + if (MarketPlace.MARKETPLACE_CURRENCY == 0) { + return List.of(new EarningsReward(EarningsReward.TYPE_CREDITS, soldPriceTotal, 0)); + } + + return List.of(new EarningsReward(EarningsReward.TYPE_POINTS, soldPriceTotal, MarketPlace.MARKETPLACE_CURRENCY)); + } + + if (category == EarningsCategory.HC_PAYDAY) { + return hcPaydayRewards(habbo); + } + + return List.of(); + } + + @Override + public boolean claim(Habbo habbo, EarningsCategory category) throws SQLException { + if (habbo == null || habbo.getClient() == null) { + return false; + } + + if (category == EarningsCategory.MARKETPLACE) { + if (habbo.getInventory().getSoldPriceTotal() <= 0) { + return false; + } + + MarketPlace.getCredits(habbo.getClient()); + return true; + } + + if (category == EarningsCategory.HC_PAYDAY) { + if (hcPaydayRewards(habbo).isEmpty()) { + return false; + } + + SubscriptionHabboClub.processUnclaimed(habbo); + return true; + } + + return false; + } + + private List hcPaydayRewards(Habbo habbo) throws SQLException { + List rewards = new ArrayList<>(); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT currency, SUM(total_payout) AS amount FROM logs_hc_payday WHERE user_id = ? AND claimed = 0 GROUP BY currency")) { + statement.setInt(1, habbo.getHabboInfo().getId()); + + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + EarningsReward reward = currencyReward(set.getString("currency"), set.getInt("amount")); + if (reward != null) { + rewards.add(reward); + } + } + } + } + + return rewards; + } + + private EarningsReward currencyReward(String currency, int amount) { + if (amount <= 0) { + return null; + } + + String normalized = currency == null ? "" : currency.trim().toLowerCase(); + return switch (normalized) { + case "credits", "credit", "coins", "coin" -> new EarningsReward(EarningsReward.TYPE_CREDITS, amount, 0); + case "duckets", "ducket", "pixels", "pixel" -> new EarningsReward(EarningsReward.TYPE_PIXELS, amount, 0); + case "diamonds", "diamond" -> new EarningsReward(EarningsReward.TYPE_POINTS, amount, 5); + default -> { + try { + yield new EarningsReward(EarningsReward.TYPE_POINTS, amount, Math.max(0, Integer.parseInt(normalized))); + } catch (NumberFormatException e) { + yield null; + } + } + }; + } + } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java index cf70814e..29a75cec 100644 --- a/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java @@ -121,6 +121,44 @@ class EarningsCenterManagerTest { 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() @@ -199,4 +237,45 @@ class EarningsCenterManagerTest { this.granted.addAll(rewards); } } + + private static class TestNativeIntegration implements EarningsCenterManager.NativeIntegration { + private final EarningsCategory category; + private final List rewards = new ArrayList<>(); + private int claims = 0; + + private TestNativeIntegration(EarningsCategory category) { + this.category = category; + } + + private TestNativeIntegration withReward(EarningsReward reward) { + this.rewards.add(reward); + return this; + } + + @Override + public boolean handles(EarningsCategory category) { + return this.category == category; + } + + @Override + public boolean hasClaim(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) { + return handles(category) && !this.rewards.isEmpty(); + } + + @Override + public List rewards(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) { + return handles(category) ? List.copyOf(this.rewards) : List.of(); + } + + @Override + public boolean claim(com.eu.habbo.habbohotel.users.Habbo habbo, EarningsCategory category) { + if (!hasClaim(habbo, category)) { + return false; + } + + this.claims++; + this.rewards.clear(); + return true; + } + } } diff --git a/docs/earnings-packet-contract.md b/docs/earnings-packet-contract.md index 09dab7a3..66d24f12 100644 --- a/docs/earnings-packet-contract.md +++ b/docs/earnings-packet-contract.md @@ -78,6 +78,11 @@ This document is the emulator-side contract for the "Guadagni" UI. For `points`, `pointsType` carries the currency type. For `badge`, `data` carries the badge code. For `item`, `data` carries the `items_base.id`. Other reward types keep `data` empty. +`marketplace` and `hc_payday` can be native rows. In native mode the amounts come from existing server state: + +- `marketplace`: sold marketplace offers waiting for payout +- `hc_payday`: unclaimed rows in `logs_hc_payday` + ## Result Status - `success` 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 4bc1720f..580bf284 100644 --- a/docs/superpowers/specs/2026-06-15-earnings-center-design.md +++ b/docs/superpowers/specs/2026-06-15-earnings-center-design.md @@ -63,8 +63,10 @@ Add emulator settings with safe defaults: - `earnings..item_id=0` - `earnings..item.quantity=1` - `earnings..hc.days=0` +- `earnings..native.enabled=0/1` The feature defaults off so existing hotels do not receive surprise economy changes after deploying the jar. +Marketplace and HC payday default to native integrations once the feature is enabled, because both already have server-side claim ledgers. ## Packet Contract @@ -90,6 +92,8 @@ Composer format is intentionally simple and renderer-friendly: category key, ena - Roll back the claim record if a DB-backed reward grant fails. - Use the database unique key to prevent concurrent double claims. - `claim all` processes only claimable rows and returns per-category results. +- Marketplace claims use the existing marketplace sold-offer payout path. +- HC payday claims use existing unclaimed `logs_hc_payday` rows. ## Tests