feat(earnings): integrate native reward sources

This commit is contained in:
simoleo89
2026-06-15 21:14:35 +02:00
parent bd9657cf63
commit 766d8d67d3
6 changed files with 302 additions and 17 deletions
+12
View File
@@ -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.');
@@ -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");
}
}
@@ -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<EarningsEntry> 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<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));
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<EarningsReward> 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<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) {
@@ -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<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;
}
}
};
}
}
}
@@ -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<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;
}
}
}
+5
View File
@@ -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`
@@ -63,8 +63,10 @@ Add emulator settings with safe defaults:
- `earnings.<category>.item_id=0`
- `earnings.<category>.item.quantity=1`
- `earnings.<category>.hc.days=0`
- `earnings.<category>.native.enabled=0/1`
The feature defaults off so existing hotels do not receive surprise economy changes after deploying the jar.
Marketplace and HC payday default to native integrations once the feature is enabled, because both already have server-side claim ledgers.
## 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