From dac83e8a626fd5ebd3147ae904167fa69037ef8f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:25:48 +0200 Subject: [PATCH 1/5] docs(earnings): define emulator rewards center --- .../plans/2026-06-15-earnings-center.md | 85 ++++++++++++++++ .../2026-06-15-earnings-center-design.md | 97 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-earnings-center.md create mode 100644 docs/superpowers/specs/2026-06-15-earnings-center-design.md diff --git a/docs/superpowers/plans/2026-06-15-earnings-center.md b/docs/superpowers/plans/2026-06-15-earnings-center.md new file mode 100644 index 00000000..636c2998 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-earnings-center.md @@ -0,0 +1,85 @@ +# Earnings Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an emulator-owned earnings/rewards hub for the new "Guadagni" UI, with server-side reward definitions and claim protection. + +**Architecture:** Add a focused earnings package under `com.eu.habbo.habbohotel.earnings`, wire three incoming handlers and two outgoing composers, and persist claims in a dedicated table with a unique period key. Keep reward definitions config-driven so UI/renderer work can progress independently. + +**Tech Stack:** Java 21, Maven, MariaDB SQL updates, existing Arcturus packet manager/composer patterns, JUnit tests. + +--- + +### Task 1: Map Existing Patterns + +**Files:** +- Read: `Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java` +- Read: `Emulator/src/main/java/com/eu/habbo/messages/PacketNames.java` +- Read: `Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java` +- Read: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/MessageComposer.java` + +- [ ] Inspect packet registration and composer header lookup. +- [ ] Inspect currency grant methods on `Habbo`. +- [ ] Inspect emulator setting access APIs. +- [ ] Choose the smallest implementation that matches existing style. + +### Task 2: Add Earnings Domain + +**Files:** +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java` +- Create: `Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java` + +- [ ] Define allowlisted categories and client keys. +- [ ] Load enabled flags, cooldowns, and reward values from configuration. +- [ ] Build row state for a user. +- [ ] Implement single claim and claim-all. +- [ ] Grant credits/pixels/points through existing `Habbo` APIs. + +### Task 3: Add Persistence + +**Files:** +- Create: `Database Updates/012_earnings_center.sql` + +- [ ] Create `users_earnings_claims`. +- [ ] Add unique key on `user_id`, `category`, `period_key`. +- [ ] Keep the migration additive and safe for existing databases. + +### Task 4: Add Packet Bridge + +**Files:** +- Create: `Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java` +- Create: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java` +- Modify: packet registration/mapping files discovered in Task 1. + +- [ ] Incoming handlers parse only category keys. +- [ ] Outgoing composers serialize rows and claim results. +- [ ] Packet names are documented for renderer alignment. + +### Task 5: Test and Build + +**Files:** +- Create: `Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java` + +- [ ] Test disabled feature behavior. +- [ ] Test unknown category rejection. +- [ ] Test single claim success. +- [ ] Test duplicate claim rejection. +- [ ] Test claim-all partial success. +- [ ] Run focused tests. +- [ ] Run `mvn clean package`. + +### Task 6: Commit and PR + +**Files:** +- Commit all source, test, SQL, spec, and plan files. + +- [ ] Commit spec and plan. +- [ ] Commit implementation. +- [ ] Push `feat/earnings-center` to `simoleo89/Arcturus-Morningstar-Extended`. +- [ ] Open ready-for-review PR to `duckietm/Arcturus-Morningstar-Extended:dev`. diff --git a/docs/superpowers/specs/2026-06-15-earnings-center-design.md b/docs/superpowers/specs/2026-06-15-earnings-center-design.md new file mode 100644 index 00000000..1f758680 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-earnings-center-design.md @@ -0,0 +1,97 @@ +# Earnings Center Design + +## Goal + +Add an emulator-owned rewards hub for the "Guadagni" UI. The client and renderer may decide how it looks, but the emulator must own reward amounts, claim eligibility, cooldowns, and anti-abuse checks. + +## Scope + +The first emulator version exposes ten earnings categories: + +- `daily_gift` +- `games` +- `achievements` +- `marketplace` +- `hc_payday` +- `level_progress` +- `donations` +- `bonus_bag` +- `mystery_boxes` +- `club_job` + +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. + +## Architecture + +Add a focused `com.eu.habbo.habbohotel.earnings` package: + +- `EarningsCenterManager` loads category definitions from emulator settings, builds per-user state, and performs claims. +- `EarningsCategory` is the allowlisted category enum and carries the client key. +- `EarningsReward` represents one configured reward. +- `EarningsEntry` is the serializable row state sent to the client. +- `EarningsClaimResult` reports single/all claim outcomes. + +The packet layer only parses category keys and delegates to the manager. The client never sends amounts, cooldowns, or reward definitions. + +## Persistence + +Add a database update that creates `users_earnings_claims`: + +- `id` +- `user_id` +- `category` +- `period_key` +- `claimed_at` +- unique key on `user_id`, `category`, `period_key` + +The unique key is the main double-claim guard. `period_key` is calculated by the emulator from the category cooldown. Daily-style rewards use the UTC date key by default. One-time or long cooldown rows can use the cooldown bucket derived from `claimed_at`. + +## Configuration + +Add emulator settings with safe defaults: + +- `earnings.enabled=0` +- `earnings..enabled=1` +- `earnings..cooldown.seconds=86400` +- `earnings..credits=0` +- `earnings..pixels=0` +- `earnings..points=0` +- `earnings..points.type=5` + +The feature defaults off so existing hotels do not receive surprise economy changes after deploying the jar. + +## Packet Contract + +Add three incoming handlers: + +- `RequestEarningsCenterEvent` +- `ClaimEarningsRewardEvent` +- `ClaimAllEarningsRewardsEvent` + +Add two outgoing composers: + +- `EarningsCenterComposer` +- `EarningsClaimResultComposer` + +Composer format is intentionally simple and renderer-friendly: category key, enabled state, claimable state, next claim timestamp, rewards, and result code. Header IDs must be wired through `messages.ini`/packet registration in the same style as the rest of the emulator. If the renderer side chooses final IDs later, only the packet mapping should need adjustment. + +## Security + +- 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. +- Use the database unique key to prevent concurrent double claims. +- `claim all` processes only claimable rows and returns per-category results. + +## Tests + +Add unit tests around the manager-level logic: + +- disabled global feature returns disabled rows and rejects claims +- unknown category is rejected +- 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 + +Packet tests can remain light because renderer IDs may be finalized separately; the critical behavior is the server-side claim guard. From e29e06201c2265c0a1b1b60182c6f23b29eb8258 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:41:00 +0200 Subject: [PATCH 2/5] feat(earnings): add emulator rewards center --- Database Updates/012_earnings_center.sql | 115 ++++++ .../src/main/java/com/eu/habbo/Emulator.java | 38 ++ .../habbohotel/earnings/EarningsCategory.java | 38 ++ .../earnings/EarningsCenterManager.java | 347 ++++++++++++++++++ .../earnings/EarningsClaimResult.java | 42 +++ .../habbohotel/earnings/EarningsEntry.java | 39 ++ .../habbohotel/earnings/EarningsReward.java | 42 +++ .../com/eu/habbo/messages/PacketManager.java | 8 + .../eu/habbo/messages/incoming/Incoming.java | 3 + .../ClaimAllEarningsRewardsEvent.java | 18 + .../earnings/ClaimEarningsRewardEvent.java | 19 + .../earnings/RequestEarningsCenterEvent.java | 18 + .../eu/habbo/messages/outgoing/Outgoing.java | 2 + .../earnings/EarningsCenterComposer.java | 44 +++ .../earnings/EarningsClaimResultComposer.java | 57 +++ .../earnings/EarningsCenterManagerTest.java | 202 ++++++++++ .../plans/2026-06-15-earnings-center.md | 4 + .../2026-06-15-earnings-center-design.md | 11 +- 18 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 Database Updates/012_earnings_center.sql create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java diff --git a/Database Updates/012_earnings_center.sql b/Database Updates/012_earnings_center.sql new file mode 100644 index 00000000..d20f5afa --- /dev/null +++ b/Database Updates/012_earnings_center.sql @@ -0,0 +1,115 @@ +CREATE TABLE IF NOT EXISTS `users_earnings_claims` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `category` varchar(64) NOT NULL, + `period_key` varchar(32) NOT NULL, + `claimed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `users_earnings_claims_unique_period` (`user_id`, `category`, `period_key`), + KEY `users_earnings_claims_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.enabled', '0', 'Enable the emulator-owned earnings center reward hub.'), +('earnings.daily_gift.enabled', '1', 'Enable daily gift earnings row.'), +('earnings.daily_gift.cooldown.seconds', '86400', 'Cooldown in seconds for daily gift earnings claims.'), +('earnings.daily_gift.credits', '0', 'Credits granted by daily gift earnings claims.'), +('earnings.daily_gift.pixels', '0', 'Pixels granted by daily gift earnings claims.'), +('earnings.daily_gift.points', '0', 'Seasonal points granted by daily gift earnings claims.'), +('earnings.daily_gift.points.type', '5', 'Seasonal point type granted by daily gift earnings claims.'), +('earnings.games.enabled', '1', 'Enable games earnings row.'), +('earnings.games.cooldown.seconds', '86400', 'Cooldown in seconds for games earnings claims.'), +('earnings.games.credits', '0', 'Credits granted by games earnings claims.'), +('earnings.games.pixels', '0', 'Pixels granted by games earnings claims.'), +('earnings.games.points', '0', 'Seasonal points granted by games earnings claims.'), +('earnings.games.points.type', '5', 'Seasonal point type granted by games earnings claims.'), +('earnings.achievements.enabled', '1', 'Enable achievements earnings row.'), +('earnings.achievements.cooldown.seconds', '86400', 'Cooldown in seconds for achievements earnings claims.'), +('earnings.achievements.credits', '0', 'Credits granted by achievements earnings claims.'), +('earnings.achievements.pixels', '0', 'Pixels granted by achievements earnings claims.'), +('earnings.achievements.points', '0', 'Seasonal points granted by achievements earnings claims.'), +('earnings.achievements.points.type', '5', 'Seasonal point type granted by achievements earnings claims.'), +('earnings.marketplace.enabled', '1', 'Enable marketplace earnings row.'), +('earnings.marketplace.cooldown.seconds', '86400', 'Cooldown in seconds for marketplace earnings claims.'), +('earnings.marketplace.credits', '0', 'Credits granted by marketplace earnings claims.'), +('earnings.marketplace.pixels', '0', 'Pixels granted by marketplace earnings claims.'), +('earnings.marketplace.points', '0', 'Seasonal points granted by marketplace earnings claims.'), +('earnings.marketplace.points.type', '5', 'Seasonal point type granted by marketplace earnings claims.'), +('earnings.hc_payday.enabled', '1', 'Enable HC payday earnings row.'), +('earnings.hc_payday.cooldown.seconds', '86400', 'Cooldown in seconds for HC payday earnings claims.'), +('earnings.hc_payday.credits', '0', 'Credits granted by HC payday earnings claims.'), +('earnings.hc_payday.pixels', '0', 'Pixels granted by HC payday earnings claims.'), +('earnings.hc_payday.points', '0', 'Seasonal points granted by HC payday earnings claims.'), +('earnings.hc_payday.points.type', '5', 'Seasonal point type granted by HC payday earnings claims.'), +('earnings.level_progress.enabled', '1', 'Enable level progress earnings row.'), +('earnings.level_progress.cooldown.seconds', '86400', 'Cooldown in seconds for level progress earnings claims.'), +('earnings.level_progress.credits', '0', 'Credits granted by level progress earnings claims.'), +('earnings.level_progress.pixels', '0', 'Pixels granted by level progress earnings claims.'), +('earnings.level_progress.points', '0', 'Seasonal points granted by level progress earnings claims.'), +('earnings.level_progress.points.type', '5', 'Seasonal point type granted by level progress earnings claims.'), +('earnings.donations.enabled', '1', 'Enable donations earnings row.'), +('earnings.donations.cooldown.seconds', '86400', 'Cooldown in seconds for donations earnings claims.'), +('earnings.donations.credits', '0', 'Credits granted by donations earnings claims.'), +('earnings.donations.pixels', '0', 'Pixels granted by donations earnings claims.'), +('earnings.donations.points', '0', 'Seasonal points granted by donations earnings claims.'), +('earnings.donations.points.type', '5', 'Seasonal point type granted by donations earnings claims.'), +('earnings.bonus_bag.enabled', '1', 'Enable bonus bag earnings row.'), +('earnings.bonus_bag.cooldown.seconds', '86400', 'Cooldown in seconds for bonus bag earnings claims.'), +('earnings.bonus_bag.credits', '0', 'Credits granted by bonus bag earnings claims.'), +('earnings.bonus_bag.pixels', '0', 'Pixels granted by bonus bag earnings claims.'), +('earnings.bonus_bag.points', '0', 'Seasonal points granted by bonus bag earnings claims.'), +('earnings.bonus_bag.points.type', '5', 'Seasonal point type granted by bonus bag earnings claims.'), +('earnings.mystery_boxes.enabled', '1', 'Enable mystery boxes earnings row.'), +('earnings.mystery_boxes.cooldown.seconds', '86400', 'Cooldown in seconds for mystery boxes earnings claims.'), +('earnings.mystery_boxes.credits', '0', 'Credits granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.pixels', '0', 'Pixels granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.points', '0', 'Seasonal points granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.points.type', '5', 'Seasonal point type granted by mystery boxes earnings claims.'), +('earnings.club_job.enabled', '1', 'Enable club and job earnings row.'), +('earnings.club_job.cooldown.seconds', '86400', 'Cooldown in seconds for club and job earnings claims.'), +('earnings.club_job.credits', '0', 'Credits granted by club and job earnings claims.'), +('earnings.club_job.pixels', '0', 'Pixels granted by club and job earnings claims.'), +('earnings.club_job.points', '0', 'Seasonal points granted by club and job earnings claims.'), +('earnings.club_job.points.type', '5', 'Seasonal point type granted by club and job earnings claims.'); + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.daily_gift.badge', '', 'Badge code granted by daily gift earnings claims.'), +('earnings.daily_gift.item_id', '0', 'Items base id granted by daily gift earnings claims.'), +('earnings.daily_gift.item.quantity', '1', 'Furni quantity granted by daily gift earnings claims.'), +('earnings.daily_gift.hc.days', '0', 'HC days granted by daily gift earnings claims.'), +('earnings.games.badge', '', 'Badge code granted by games earnings claims.'), +('earnings.games.item_id', '0', 'Items base id granted by games earnings claims.'), +('earnings.games.item.quantity', '1', 'Furni quantity granted by games earnings claims.'), +('earnings.games.hc.days', '0', 'HC days granted by games earnings claims.'), +('earnings.achievements.badge', '', 'Badge code granted by achievements earnings claims.'), +('earnings.achievements.item_id', '0', 'Items base id granted by achievements earnings claims.'), +('earnings.achievements.item.quantity', '1', 'Furni quantity granted by achievements earnings claims.'), +('earnings.achievements.hc.days', '0', 'HC days granted by achievements earnings claims.'), +('earnings.marketplace.badge', '', 'Badge code granted by marketplace earnings claims.'), +('earnings.marketplace.item_id', '0', 'Items base id granted by marketplace earnings claims.'), +('earnings.marketplace.item.quantity', '1', 'Furni quantity granted by marketplace earnings claims.'), +('earnings.marketplace.hc.days', '0', 'HC days granted by marketplace earnings claims.'), +('earnings.hc_payday.badge', '', 'Badge code granted by HC payday earnings claims.'), +('earnings.hc_payday.item_id', '0', 'Items base id granted by HC payday earnings claims.'), +('earnings.hc_payday.item.quantity', '1', 'Furni quantity granted by HC payday earnings claims.'), +('earnings.hc_payday.hc.days', '0', 'HC days granted by HC payday earnings claims.'), +('earnings.level_progress.badge', '', 'Badge code granted by level progress earnings claims.'), +('earnings.level_progress.item_id', '0', 'Items base id granted by level progress earnings claims.'), +('earnings.level_progress.item.quantity', '1', 'Furni quantity granted by level progress earnings claims.'), +('earnings.level_progress.hc.days', '0', 'HC days granted by level progress earnings claims.'), +('earnings.donations.badge', '', 'Badge code granted by donations earnings claims.'), +('earnings.donations.item_id', '0', 'Items base id granted by donations earnings claims.'), +('earnings.donations.item.quantity', '1', 'Furni quantity granted by donations earnings claims.'), +('earnings.donations.hc.days', '0', 'HC days granted by donations earnings claims.'), +('earnings.bonus_bag.badge', '', 'Badge code granted by bonus bag earnings claims.'), +('earnings.bonus_bag.item_id', '0', 'Items base id granted by bonus bag earnings claims.'), +('earnings.bonus_bag.item.quantity', '1', 'Furni quantity granted by bonus bag earnings claims.'), +('earnings.bonus_bag.hc.days', '0', 'HC days granted by bonus bag earnings claims.'), +('earnings.mystery_boxes.badge', '', 'Badge code granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.item_id', '0', 'Items base id granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.item.quantity', '1', 'Furni quantity granted by mystery boxes earnings claims.'), +('earnings.mystery_boxes.hc.days', '0', 'HC days granted by mystery boxes earnings claims.'), +('earnings.club_job.badge', '', 'Badge code granted by club and job earnings claims.'), +('earnings.club_job.item_id', '0', 'Items base id granted by club and job earnings claims.'), +('earnings.club_job.item.quantity', '1', 'Furni quantity granted by club and job earnings claims.'), +('earnings.club_job.hc.days', '0', 'HC days granted by club and job earnings claims.'); diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 720b6af0..8d4a95fe 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -160,6 +160,13 @@ public final class Emulator { Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); Emulator.config.register("gui.enabled", "0"); Emulator.config.register("gui.autostart.enabled", "0"); + Emulator.config.register("rcon.rate_limit.enabled", "1"); + Emulator.config.register("rcon.rate_limit.limit_for_period", "60"); + Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000"); + Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); + Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_give_rank"); + Emulator.config.register("rcon.execute_command.allowed_permissions", ""); + registerEarningsSettings(); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(startupCard(hotelTimezoneId)); Emulator.texts.register("camera.permission", "You don't have permission to use the camera!"); @@ -482,6 +489,37 @@ public final class Emulator { return gameServer; } + private static void registerEarningsSettings() { + Emulator.config.register("earnings.enabled", "0"); + + String[] categories = { + "daily_gift", + "games", + "achievements", + "marketplace", + "hc_payday", + "level_progress", + "donations", + "bonus_bag", + "mystery_boxes", + "club_job" + }; + + for (String category : categories) { + String prefix = "earnings." + category + "."; + Emulator.config.register(prefix + "enabled", "1"); + Emulator.config.register(prefix + "cooldown.seconds", "86400"); + Emulator.config.register(prefix + "credits", "0"); + Emulator.config.register(prefix + "pixels", "0"); + Emulator.config.register(prefix + "points", "0"); + Emulator.config.register(prefix + "points.type", "5"); + Emulator.config.register(prefix + "badge", ""); + Emulator.config.register(prefix + "item_id", "0"); + Emulator.config.register(prefix + "item.quantity", "1"); + Emulator.config.register(prefix + "hc.days", "0"); + } + } + public static RCONServer getRconServer() { return rconServer; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java new file mode 100644 index 00000000..547df6c5 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCategory.java @@ -0,0 +1,38 @@ +package com.eu.habbo.habbohotel.earnings; + +import java.util.Arrays; +import java.util.Optional; + +public enum EarningsCategory { + DAILY_GIFT("daily_gift"), + GAMES("games"), + ACHIEVEMENTS("achievements"), + MARKETPLACE("marketplace"), + HC_PAYDAY("hc_payday"), + LEVEL_PROGRESS("level_progress"), + DONATIONS("donations"), + BONUS_BAG("bonus_bag"), + MYSTERY_BOXES("mystery_boxes"), + CLUB_JOB("club_job"); + + private final String key; + + EarningsCategory(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + public static Optional fromKey(String key) { + if (key == null || key.isBlank()) { + return Optional.empty(); + } + + String normalized = key.trim().toLowerCase(); + return Arrays.stream(values()) + .filter(category -> category.key.equals(normalized)) + .findFirst(); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java new file mode 100644 index 00000000..32514c51 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManager.java @@ -0,0 +1,347 @@ +package com.eu.habbo.habbohotel.earnings; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboBadge; +import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class EarningsCenterManager { + public static final String CONFIG_PREFIX = "earnings."; + private static final int DEFAULT_COOLDOWN_SECONDS = 86400; + private static final int DEFAULT_POINTS_TYPE = 5; + private static final int MAX_CONFIGURED_REWARD = 1_000_000; + private static final int MAX_ITEM_QUANTITY = 100; + private static final int MAX_HC_DAYS = 365; + + private final ConfigSource config; + private final ClaimRepository claims; + private final RewardApplier rewards; + private final Clock clock; + + public EarningsCenterManager() { + this(new EmulatorConfigSource(), new JdbcClaimRepository(), new HabboRewardApplier(), Clock.systemUTC()); + } + + public EarningsCenterManager(ConfigSource config, ClaimRepository claims, RewardApplier rewards, Clock clock) { + this.config = config; + this.claims = claims; + this.rewards = rewards; + this.clock = clock; + } + + public List getEntries(Habbo habbo) { + int userId = getUserId(habbo); + int now = now(); + List entries = new ArrayList<>(); + + for (EarningsCategory category : EarningsCategory.values()) { + entries.add(buildEntry(userId, category, now)); + } + + return entries; + } + + public EarningsClaimResult claim(Habbo habbo, String categoryKey) { + Optional requestedCategory = EarningsCategory.fromKey(categoryKey); + if (requestedCategory.isEmpty()) { + return new EarningsClaimResult(null, EarningsClaimResult.Status.UNKNOWN_CATEGORY, null); + } + + return claim(habbo, requestedCategory.get()); + } + + public List claimAll(Habbo habbo) { + List results = new ArrayList<>(); + + for (EarningsCategory category : EarningsCategory.values()) { + results.add(claim(habbo, category)); + } + + return results; + } + + private EarningsClaimResult claim(Habbo habbo, EarningsCategory category) { + int userId = getUserId(habbo); + int now = now(); + CategoryDefinition definition = loadDefinition(category); + + if (!definition.enabled()) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.DISABLED, buildEntry(userId, category, now)); + } + + if (definition.rewards().isEmpty()) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(userId, category, now)); + } + + String periodKey = periodKey(now, definition.cooldownSeconds()); + + try { + if (!this.claims.recordClaim(userId, category.getKey(), periodKey, now)) { + return new EarningsClaimResult(category, EarningsClaimResult.Status.ALREADY_CLAIMED, buildEntry(userId, category, now)); + } + + this.rewards.grant(habbo, definition.rewards()); + return new EarningsClaimResult(category, EarningsClaimResult.Status.SUCCESS, buildEntry(userId, category, now)); + } catch (SQLException e) { + try { + this.claims.removeClaim(userId, category.getKey(), periodKey); + } catch (SQLException ignored) { + } + return new EarningsClaimResult(category, EarningsClaimResult.Status.ERROR, buildEntry(userId, category, now)); + } + } + + private EarningsEntry buildEntry(int userId, EarningsCategory category, int now) { + CategoryDefinition definition = loadDefinition(category); + boolean claimable = false; + int nextClaimAt = 0; + + if (definition.enabled() && !definition.rewards().isEmpty()) { + String periodKey = periodKey(now, definition.cooldownSeconds()); + + try { + claimable = !this.claims.hasClaim(userId, category.getKey(), periodKey); + nextClaimAt = claimable ? 0 : nextPeriodStart(now, definition.cooldownSeconds()); + } catch (SQLException e) { + claimable = false; + nextClaimAt = nextPeriodStart(now, definition.cooldownSeconds()); + } + } + + return new EarningsEntry(category, definition.enabled(), claimable, nextClaimAt, definition.rewards()); + } + + private CategoryDefinition loadDefinition(EarningsCategory category) { + String key = CONFIG_PREFIX + category.getKey() + "."; + boolean enabled = this.config.getBoolean(CONFIG_PREFIX + "enabled", false) + && this.config.getBoolean(key + "enabled", true); + int cooldown = Math.max(60, this.config.getInt(key + "cooldown.seconds", DEFAULT_COOLDOWN_SECONDS)); + int pointsType = Math.max(0, this.config.getInt(key + "points.type", DEFAULT_POINTS_TYPE)); + List rewards = new ArrayList<>(); + + addReward(rewards, EarningsReward.TYPE_CREDITS, this.config.getInt(key + "credits", 0), 0); + addReward(rewards, EarningsReward.TYPE_PIXELS, this.config.getInt(key + "pixels", 0), 0); + addReward(rewards, EarningsReward.TYPE_POINTS, this.config.getInt(key + "points", 0), pointsType); + addBadgeReward(rewards, this.config.getValue(key + "badge", "")); + addItemReward(rewards, this.config.getInt(key + "item_id", 0), this.config.getInt(key + "item.quantity", 1)); + addHcReward(rewards, this.config.getInt(key + "hc.days", 0)); + + return new CategoryDefinition(enabled, cooldown, rewards); + } + + private void addReward(List rewards, String type, int amount, int pointsType) { + int clampedAmount = Math.min(Math.max(0, amount), MAX_CONFIGURED_REWARD); + if (clampedAmount > 0) { + rewards.add(new EarningsReward(type, clampedAmount, pointsType)); + } + } + + private void addBadgeReward(List rewards, String badgeCode) { + if (badgeCode == null || !badgeCode.matches("[A-Za-z0-9_\\-]{1,64}")) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_BADGE, 1, 0, badgeCode)); + } + + private void addItemReward(List rewards, int itemId, int quantity) { + if (itemId <= 0 || quantity <= 0) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_ITEM, Math.min(quantity, MAX_ITEM_QUANTITY), 0, String.valueOf(itemId))); + } + + private void addHcReward(List rewards, int days) { + if (days <= 0) { + return; + } + + rewards.add(new EarningsReward(EarningsReward.TYPE_HC_DAYS, Math.min(days, MAX_HC_DAYS), 0)); + } + + private int getUserId(Habbo habbo) { + if (habbo == null || habbo.getHabboInfo() == null) { + return 0; + } + + return habbo.getHabboInfo().getId(); + } + + private int now() { + return (int) (this.clock.instant().getEpochSecond()); + } + + private String periodKey(int now, int cooldownSeconds) { + return String.valueOf(now / cooldownSeconds); + } + + private int nextPeriodStart(int now, int cooldownSeconds) { + return ((now / cooldownSeconds) + 1) * cooldownSeconds; + } + + private record CategoryDefinition(boolean enabled, int cooldownSeconds, List rewards) { + } + + public interface ConfigSource { + boolean getBoolean(String key, boolean defaultValue); + + int getInt(String key, int defaultValue); + + String getValue(String key, String defaultValue); + } + + public interface ClaimRepository { + boolean hasClaim(int userId, String category, String periodKey) throws SQLException; + + boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException; + + void removeClaim(int userId, String category, String periodKey) throws SQLException; + } + + public interface RewardApplier { + void grant(Habbo habbo, List rewards) throws SQLException; + } + + private static class EmulatorConfigSource implements ConfigSource { + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return Emulator.getConfig().getBoolean(key, defaultValue); + } + + @Override + public int getInt(String key, int defaultValue) { + return Emulator.getConfig().getInt(key, defaultValue); + } + + @Override + public String getValue(String key, String defaultValue) { + return Emulator.getConfig().getValue(key, defaultValue); + } + } + + private static class JdbcClaimRepository implements ClaimRepository { + @Override + public boolean hasClaim(int userId, String category, String periodKey) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT 1 FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + return statement.executeQuery().next(); + } + } + + @Override + public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("INSERT INTO users_earnings_claims (user_id, category, period_key, claimed_at) VALUES (?, ?, ?, FROM_UNIXTIME(?))")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + statement.setInt(4, claimedAt); + return statement.executeUpdate() == 1; + } catch (SQLIntegrityConstraintViolationException duplicate) { + return false; + } + } + + @Override + public void removeClaim(int userId, String category, String periodKey) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM users_earnings_claims WHERE user_id = ? AND category = ? AND period_key = ? LIMIT 1")) { + statement.setInt(1, userId); + statement.setString(2, category); + statement.setString(3, periodKey); + statement.executeUpdate(); + } + } + } + + private static class HabboRewardApplier implements RewardApplier { + @Override + public void grant(Habbo habbo, List rewards) throws SQLException { + if (habbo == null) { + return; + } + + for (EarningsReward reward : rewards) { + switch (reward.getType()) { + case EarningsReward.TYPE_CREDITS -> habbo.giveCredits(reward.getAmount()); + case EarningsReward.TYPE_PIXELS -> habbo.givePixels(reward.getAmount()); + case EarningsReward.TYPE_POINTS -> habbo.givePoints(reward.getPointsType(), reward.getAmount()); + case EarningsReward.TYPE_BADGE -> grantBadge(habbo, reward.getData()); + case EarningsReward.TYPE_ITEM -> grantItem(habbo, Integer.parseInt(reward.getData()), reward.getAmount()); + case EarningsReward.TYPE_HC_DAYS -> grantHcDays(habbo, reward.getAmount()); + default -> { + } + } + } + } + + private void grantBadge(Habbo habbo, String badgeCode) throws SQLException { + if (habbo.getInventory().getBadgesComponent().hasBadge(badgeCode)) { + return; + } + + HabboBadge badge = new HabboBadge(0, badgeCode, 0, habbo); + badge.run(); + habbo.getInventory().getBadgesComponent().addBadge(badge); + if (habbo.getClient() != null) { + habbo.getClient().sendResponse(new AddUserBadgeComposer(badge)); + } + } + + private void grantItem(Habbo habbo, int itemId, int quantity) throws SQLException { + if (!itemExists(itemId)) { + throw new SQLException("Unknown earnings item reward " + itemId); + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("INSERT INTO items (user_id, item_id, extra_data) VALUES (?, ?, '')")) { + for (int i = 0; i < quantity; i++) { + statement.setInt(1, habbo.getHabboInfo().getId()); + statement.setInt(2, itemId); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private boolean itemExists(int itemId) throws SQLException { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT id FROM items_base WHERE id = ? LIMIT 1")) { + statement.setInt(1, itemId); + try (ResultSet set = statement.executeQuery()) { + return set.next(); + } + } + } + + private void grantHcDays(Habbo habbo, int days) throws SQLException { + int now = Emulator.getIntUnixTimestamp(); + int current = habbo.getHabboStats().getClubExpireTimestamp(); + int newExpire = (current > now ? current : now) + (days * 86400); + + habbo.getHabboStats().setClubExpireTimestamp(newExpire); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET club_expire_timestamp = ? WHERE user_id = ? LIMIT 1")) { + statement.setInt(1, newExpire); + statement.setInt(2, habbo.getHabboInfo().getId()); + statement.executeUpdate(); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java new file mode 100644 index 00000000..ab5dc896 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsClaimResult.java @@ -0,0 +1,42 @@ +package com.eu.habbo.habbohotel.earnings; + +public class EarningsClaimResult { + public enum Status { + SUCCESS, + DISABLED, + UNKNOWN_CATEGORY, + ALREADY_CLAIMED, + NO_REWARD, + ERROR + } + + private final EarningsCategory category; + private final Status status; + private final EarningsEntry entry; + + public EarningsClaimResult(EarningsCategory category, Status status, EarningsEntry entry) { + this.category = category; + this.status = status; + this.entry = entry; + } + + public EarningsCategory getCategory() { + return category; + } + + public String getCategoryKey() { + return category == null ? "" : category.getKey(); + } + + public Status getStatus() { + return status; + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public EarningsEntry getEntry() { + return entry; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java new file mode 100644 index 00000000..2fdf2318 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsEntry.java @@ -0,0 +1,39 @@ +package com.eu.habbo.habbohotel.earnings; + +import java.util.List; + +public class EarningsEntry { + private final EarningsCategory category; + private final boolean enabled; + private final boolean claimable; + private final int nextClaimAt; + private final List rewards; + + public EarningsEntry(EarningsCategory category, boolean enabled, boolean claimable, int nextClaimAt, List rewards) { + this.category = category; + this.enabled = enabled; + this.claimable = claimable; + this.nextClaimAt = Math.max(0, nextClaimAt); + this.rewards = List.copyOf(rewards); + } + + public EarningsCategory getCategory() { + return category; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isClaimable() { + return claimable; + } + + public int getNextClaimAt() { + return nextClaimAt; + } + + public List getRewards() { + return rewards; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java new file mode 100644 index 00000000..9531c6a7 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/earnings/EarningsReward.java @@ -0,0 +1,42 @@ +package com.eu.habbo.habbohotel.earnings; + +public class EarningsReward { + public static final String TYPE_CREDITS = "credits"; + public static final String TYPE_PIXELS = "pixels"; + public static final String TYPE_POINTS = "points"; + public static final String TYPE_BADGE = "badge"; + public static final String TYPE_ITEM = "item"; + public static final String TYPE_HC_DAYS = "hc_days"; + + private final String type; + private final int amount; + private final int pointsType; + private final String data; + + public EarningsReward(String type, int amount, int pointsType) { + this(type, amount, pointsType, ""); + } + + public EarningsReward(String type, int amount, int pointsType, String data) { + this.type = type; + this.amount = Math.max(0, amount); + this.pointsType = Math.max(0, pointsType); + this.data = data == null ? "" : data; + } + + public String getType() { + return type; + } + + public int getAmount() { + return amount; + } + + public int getPointsType() { + return pointsType; + } + + public String getData() { + return data; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index 9f372c5b..7bf5460b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -21,6 +21,7 @@ import com.eu.habbo.messages.incoming.catalog.recycler.RequestRecyclerLogicEvent import com.eu.habbo.messages.incoming.crafting.*; import com.eu.habbo.messages.incoming.events.calendar.AdventCalendarForceOpenEvent; import com.eu.habbo.messages.incoming.events.calendar.AdventCalendarOpenDayEvent; +import com.eu.habbo.messages.incoming.earnings.*; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorRequestBlockedTilesEvent; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorRequestDoorSettingsEvent; import com.eu.habbo.messages.incoming.floorplaneditor.FloorPlanEditorSaveEvent; @@ -130,6 +131,7 @@ public class PacketManager { this.registerCrafting(); this.registerCamera(); this.registerGameCenter(); + this.registerEarnings(); } public PacketNames getNames() { @@ -766,4 +768,10 @@ public class PacketManager { this.registerHandler(Incoming.SoundboardPlayEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardPlayEvent.class); this.registerHandler(Incoming.SoundboardSetEnabledEvent, com.eu.habbo.messages.incoming.soundboard.SoundboardSetEnabledEvent.class); } + + void registerEarnings() throws Exception { + this.registerHandler(Incoming.RequestEarningsCenterEvent, RequestEarningsCenterEvent.class); + this.registerHandler(Incoming.ClaimEarningsRewardEvent, ClaimEarningsRewardEvent.class); + this.registerHandler(Incoming.ClaimAllEarningsRewardsEvent, ClaimAllEarningsRewardsEvent.class); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 3a0bdf1a..cd8ab2c6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -501,6 +501,9 @@ public class Incoming { public static final int WheelAdminSavePrizesEvent = 9305; public static final int SoundboardPlayEvent = 9306; public static final int SoundboardSetEnabledEvent = 9307; + public static final int RequestEarningsCenterEvent = 9308; + public static final int ClaimEarningsRewardEvent = 9309; + public static final int ClaimAllEarningsRewardsEvent = 9310; public static final int RequestMentionsEvent = 4803; public static final int MarkMentionsReadEvent = 4804; public static final int DeleteMentionEvent = 4805; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java new file mode 100644 index 00000000..c83d0d69 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimAllEarningsRewardsEvent.java @@ -0,0 +1,18 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsClaimResultComposer; + +public class ClaimAllEarningsRewardsEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 3000; + } + + @Override + public void handle() { + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsClaimResultComposer(manager.claimAll(this.client.getHabbo()))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java new file mode 100644 index 00000000..f11d8d86 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/ClaimEarningsRewardEvent.java @@ -0,0 +1,19 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsClaimResultComposer; + +public class ClaimEarningsRewardEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 1000; + } + + @Override + public void handle() { + String categoryKey = this.packet.readString(); + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsClaimResultComposer(manager.claim(this.client.getHabbo(), categoryKey))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java new file mode 100644 index 00000000..b138a96e --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/earnings/RequestEarningsCenterEvent.java @@ -0,0 +1,18 @@ +package com.eu.habbo.messages.incoming.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.earnings.EarningsCenterComposer; + +public class RequestEarningsCenterEvent extends MessageHandler { + @Override + public int getRatelimit() { + return 500; + } + + @Override + public void handle() { + EarningsCenterManager manager = new EarningsCenterManager(); + this.client.sendResponse(new EarningsCenterComposer(manager.getEntries(this.client.getHabbo()))); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index c52ef526..6d67512d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -607,6 +607,8 @@ public class Outgoing { public static final int WheelAdminPrizesComposer = 9404; public static final int SoundboardSettingsComposer = 9405; public static final int SoundboardPlayComposer = 9406; + public static final int EarningsCenterComposer = 9407; + public static final int EarningsClaimResultComposer = 9408; public static final int MentionReceivedComposer = 4801; public static final int MentionsListComposer = 4802; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java new file mode 100644 index 00000000..9330cecf --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsCenterComposer.java @@ -0,0 +1,44 @@ +package com.eu.habbo.messages.outgoing.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsEntry; +import com.eu.habbo.habbohotel.earnings.EarningsReward; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class EarningsCenterComposer extends MessageComposer { + private final List entries; + + public EarningsCenterComposer(List entries) { + this.entries = entries; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.EarningsCenterComposer); + this.response.appendInt(this.entries.size()); + + for (EarningsEntry entry : this.entries) { + serializeEntry(entry); + } + + return this.response; + } + + private void serializeEntry(EarningsEntry entry) { + this.response.appendString(entry.getCategory().getKey()); + this.response.appendBoolean(entry.isEnabled()); + this.response.appendBoolean(entry.isClaimable()); + this.response.appendInt(entry.getNextClaimAt()); + this.response.appendInt(entry.getRewards().size()); + + for (EarningsReward reward : entry.getRewards()) { + this.response.appendString(reward.getType()); + this.response.appendInt(reward.getAmount()); + this.response.appendInt(reward.getPointsType()); + this.response.appendString(reward.getData()); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java new file mode 100644 index 00000000..8b807896 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/earnings/EarningsClaimResultComposer.java @@ -0,0 +1,57 @@ +package com.eu.habbo.messages.outgoing.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsClaimResult; +import com.eu.habbo.habbohotel.earnings.EarningsEntry; +import com.eu.habbo.habbohotel.earnings.EarningsReward; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class EarningsClaimResultComposer extends MessageComposer { + private final List results; + + public EarningsClaimResultComposer(EarningsClaimResult result) { + this.results = List.of(result); + } + + public EarningsClaimResultComposer(List results) { + this.results = results; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.EarningsClaimResultComposer); + this.response.appendInt(this.results.size()); + + for (EarningsClaimResult result : this.results) { + this.response.appendString(result.getCategoryKey()); + this.response.appendString(result.getStatus().name().toLowerCase()); + this.response.appendBoolean(result.isSuccess()); + serializeEntry(result.getEntry()); + } + + return this.response; + } + + private void serializeEntry(EarningsEntry entry) { + this.response.appendBoolean(entry != null); + if (entry == null) { + return; + } + + this.response.appendString(entry.getCategory().getKey()); + this.response.appendBoolean(entry.isEnabled()); + this.response.appendBoolean(entry.isClaimable()); + this.response.appendInt(entry.getNextClaimAt()); + this.response.appendInt(entry.getRewards().size()); + + for (EarningsReward reward : entry.getRewards()) { + this.response.appendString(reward.getType()); + this.response.appendInt(reward.getAmount()); + this.response.appendInt(reward.getPointsType()); + this.response.appendString(reward.getData()); + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java new file mode 100644 index 00000000..cf70814e --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/earnings/EarningsCenterManagerTest.java @@ -0,0 +1,202 @@ +package com.eu.habbo.habbohotel.earnings; + +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ClaimRepository; +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.ConfigSource; +import com.eu.habbo.habbohotel.earnings.EarningsCenterManager.RewardApplier; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EarningsCenterManagerTest { + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.ofEpochSecond(1_800_000_000L), ZoneOffset.UTC); + + @Test + void disabledFeatureReturnsDisabledEntriesAndRejectsClaims() { + TestConfig config = new TestConfig().with("earnings.enabled", "0"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + List entries = manager.getEntries(null); + EarningsClaimResult result = manager.claim(null, "daily_gift"); + + assertFalse(entries.getFirst().isEnabled()); + assertFalse(entries.getFirst().isClaimable()); + assertEquals(EarningsClaimResult.Status.DISABLED, result.getStatus()); + assertTrue(rewards.granted.isEmpty()); + } + + @Test + void unknownCategoryIsRejected() { + EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsClaimResult result = manager.claim(null, "not_real"); + + assertEquals(EarningsClaimResult.Status.UNKNOWN_CATEGORY, result.getStatus()); + } + + @Test + void successfulClaimGrantsConfiguredRewardOnce() { + TestConfig config = enabledConfig() + .with("earnings.daily_gift.credits", "25") + .with("earnings.daily_gift.points", "3") + .with("earnings.daily_gift.points.type", "7"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + EarningsClaimResult first = manager.claim(null, "daily_gift"); + EarningsClaimResult duplicate = manager.claim(null, "daily_gift"); + + assertEquals(EarningsClaimResult.Status.SUCCESS, first.getStatus()); + assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, duplicate.getStatus()); + assertEquals(2, rewards.granted.size()); + assertEquals(EarningsReward.TYPE_CREDITS, rewards.granted.get(0).getType()); + assertEquals(25, rewards.granted.get(0).getAmount()); + assertEquals(EarningsReward.TYPE_POINTS, rewards.granted.get(1).getType()); + assertEquals(7, rewards.granted.get(1).getPointsType()); + } + + @Test + void categoryWithNoConfiguredRewardIsNotClaimable() { + EarningsCenterManager manager = new EarningsCenterManager(enabledConfig(), new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsClaimResult result = manager.claim(null, "games"); + + assertEquals(EarningsClaimResult.Status.NO_REWARD, result.getStatus()); + assertFalse(result.getEntry().isClaimable()); + } + + @Test + void configurableBadgeItemAndHcRewardsAreIncludedInEntryState() { + TestConfig config = enabledConfig() + .with("earnings.bonus_bag.badge", "ACH_Test1") + .with("earnings.bonus_bag.item_id", "123") + .with("earnings.bonus_bag.item.quantity", "2") + .with("earnings.bonus_bag.hc.days", "7"); + EarningsCenterManager manager = new EarningsCenterManager(config, new TestClaims(), new TestRewards(), FIXED_CLOCK); + + EarningsEntry entry = manager.getEntries(null).stream() + .filter(current -> current.getCategory() == EarningsCategory.BONUS_BAG) + .findFirst() + .orElseThrow(); + + assertTrue(entry.isClaimable()); + assertEquals(3, entry.getRewards().size()); + assertEquals(EarningsReward.TYPE_BADGE, entry.getRewards().get(0).getType()); + assertEquals("ACH_Test1", entry.getRewards().get(0).getData()); + assertEquals(EarningsReward.TYPE_ITEM, entry.getRewards().get(1).getType()); + assertEquals("123", entry.getRewards().get(1).getData()); + assertEquals(2, entry.getRewards().get(1).getAmount()); + assertEquals(EarningsReward.TYPE_HC_DAYS, entry.getRewards().get(2).getType()); + assertEquals(7, entry.getRewards().get(2).getAmount()); + } + + @Test + void failedRewardGrantRollsBackClaimRecord() { + TestConfig config = enabledConfig().with("earnings.daily_gift.credits", "10"); + TestClaims claims = new TestClaims(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, (habbo, rewards) -> { + throw new SQLException("grant failed"); + }, FIXED_CLOCK); + + EarningsClaimResult failed = manager.claim(null, "daily_gift"); + EarningsClaimResult retried = new EarningsCenterManager(config, claims, new TestRewards(), FIXED_CLOCK) + .claim(null, "daily_gift"); + + assertEquals(EarningsClaimResult.Status.ERROR, failed.getStatus()); + assertEquals(EarningsClaimResult.Status.SUCCESS, retried.getStatus()); + } + + @Test + void claimAllGrantsClaimableRowsAndSkipsAlreadyClaimedRows() throws SQLException { + TestConfig config = enabledConfig() + .with("earnings.daily_gift.credits", "10") + .with("earnings.games.pixels", "4"); + TestClaims claims = new TestClaims(); + TestRewards rewards = new TestRewards(); + EarningsCenterManager manager = new EarningsCenterManager(config, claims, rewards, FIXED_CLOCK); + + claims.recordClaim(0, "daily_gift", String.valueOf(1_800_000_000L / 86400), 1_800_000_000); + List results = manager.claimAll(null); + + assertEquals(EarningsClaimResult.Status.ALREADY_CLAIMED, results.get(0).getStatus()); + assertEquals(EarningsClaimResult.Status.SUCCESS, results.get(1).getStatus()); + assertEquals(1, rewards.granted.size()); + assertEquals(EarningsReward.TYPE_PIXELS, rewards.granted.getFirst().getType()); + assertEquals(4, rewards.granted.getFirst().getAmount()); + } + + private static TestConfig enabledConfig() { + return new TestConfig().with("earnings.enabled", "1"); + } + + private static class TestConfig implements ConfigSource { + private final Map values = new HashMap<>(); + + TestConfig with(String key, String value) { + this.values.put(key, value); + return this; + } + + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return this.values.getOrDefault(key, defaultValue ? "1" : "0").equals("1"); + } + + @Override + public int getInt(String key, int defaultValue) { + return Integer.parseInt(this.values.getOrDefault(key, String.valueOf(defaultValue))); + } + + @Override + public String getValue(String key, String defaultValue) { + return this.values.getOrDefault(key, defaultValue); + } + } + + private static class TestClaims implements ClaimRepository { + private final Set claims = new HashSet<>(); + + @Override + public boolean hasClaim(int userId, String category, String periodKey) { + return this.claims.contains(key(userId, category, periodKey)); + } + + @Override + public boolean recordClaim(int userId, String category, String periodKey, int claimedAt) { + return this.claims.add(key(userId, category, periodKey)); + } + + @Override + public void removeClaim(int userId, String category, String periodKey) { + this.claims.remove(key(userId, category, periodKey)); + } + + private String key(int userId, String category, String periodKey) { + return userId + ":" + category + ":" + periodKey; + } + } + + private static class TestRewards implements RewardApplier { + private final List granted = new ArrayList<>(); + + @Override + public void grant(com.eu.habbo.habbohotel.users.Habbo habbo, List rewards) { + this.granted.addAll(rewards); + } + } +} diff --git a/docs/superpowers/plans/2026-06-15-earnings-center.md b/docs/superpowers/plans/2026-06-15-earnings-center.md index 636c2998..9df4750b 100644 --- a/docs/superpowers/plans/2026-06-15-earnings-center.md +++ b/docs/superpowers/plans/2026-06-15-earnings-center.md @@ -37,6 +37,8 @@ - [ ] Build row state for a user. - [ ] Implement single claim and claim-all. - [ ] Grant credits/pixels/points through existing `Habbo` APIs. +- [ ] Grant badges, furni items, and HC days through existing emulator storage paths. +- [ ] Roll back a claim marker if a DB-backed grant fails. ### Task 3: Add Persistence @@ -71,6 +73,8 @@ - [ ] Test single claim success. - [ ] Test duplicate claim rejection. - [ ] Test claim-all partial success. +- [ ] Test badge, item, and HC reward serialization state. +- [ ] Test claim rollback after grant failure. - [ ] Run focused tests. - [ ] Run `mvn clean package`. diff --git a/docs/superpowers/specs/2026-06-15-earnings-center-design.md b/docs/superpowers/specs/2026-06-15-earnings-center-design.md index 1f758680..4bc1720f 100644 --- a/docs/superpowers/specs/2026-06-15-earnings-center-design.md +++ b/docs/superpowers/specs/2026-06-15-earnings-center-design.md @@ -21,6 +21,8 @@ The first emulator version exposes ten earnings categories: Every category can be enabled, disabled, configured with one or more reward currencies, and claimed through a single-row claim or a claim-all request. Categories that are not yet backed by a native hotel subsystem still work through static configuration, so the UI contract is stable while deeper integrations are added later. +Supported configured reward types are credits, pixels/duckets, seasonal points, badges, furni items, and HC days. + ## Architecture Add a focused `com.eu.habbo.habbohotel.earnings` package: @@ -57,6 +59,10 @@ Add emulator settings with safe defaults: - `earnings..pixels=0` - `earnings..points=0` - `earnings..points.type=5` +- `earnings..badge=` +- `earnings..item_id=0` +- `earnings..item.quantity=1` +- `earnings..hc.days=0` The feature defaults off so existing hotels do not receive surprise economy changes after deploying the jar. @@ -80,7 +86,8 @@ Composer format is intentionally simple and renderer-friendly: category key, ena - Reject unknown category keys. - Reject all claims when `earnings.enabled=0`. - Never trust reward amounts from the client. -- Clamp configured rewards to non-negative values. +- Clamp configured rewards to non-negative values and bounded item/HC limits. +- Roll back the claim record if a DB-backed reward grant fails. - Use the database unique key to prevent concurrent double claims. - `claim all` processes only claimable rows and returns per-category results. @@ -93,5 +100,7 @@ Add unit tests around the manager-level logic: - successful claim grants configured currency once - duplicate claim in the same period is rejected - claim-all grants all claimable rows and skips already claimed rows +- badge/item/HC reward rows are included in state +- failed reward grants roll back the claim record Packet tests can remain light because renderer IDs may be finalized separately; the critical behavior is the server-side claim guard. From bd9657cf638bc20f6b1ff4c81ab57fd63d440125 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:48:43 +0200 Subject: [PATCH 3/5] docs(earnings): document renderer packet contract --- docs/earnings-packet-contract.md | 90 ++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/earnings-packet-contract.md diff --git a/docs/earnings-packet-contract.md b/docs/earnings-packet-contract.md new file mode 100644 index 00000000..09dab7a3 --- /dev/null +++ b/docs/earnings-packet-contract.md @@ -0,0 +1,90 @@ +# Earnings Center Packet Contract + +This document is the emulator-side contract for the "Guadagni" UI. + +## Incoming + +### `RequestEarningsCenterEvent` + +- Header: `9308` +- Body: empty +- Response: `EarningsCenterComposer` + +### `ClaimEarningsRewardEvent` + +- Header: `9309` +- Body: + - `String categoryKey` +- Response: `EarningsClaimResultComposer` + +### `ClaimAllEarningsRewardsEvent` + +- Header: `9310` +- Body: empty +- Response: `EarningsClaimResultComposer` + +## Outgoing + +### `EarningsCenterComposer` + +- Header: `9407` +- Body: + - `int entryCount` + - repeated entry: + - `String categoryKey` + - `boolean enabled` + - `boolean claimable` + - `int nextClaimAt` + - `int rewardCount` + - repeated reward: + - `String type` + - `int amount` + - `int pointsType` + - `String data` + +### `EarningsClaimResultComposer` + +- Header: `9408` +- Body: + - `int resultCount` + - repeated result: + - `String categoryKey` + - `String status` + - `boolean success` + - `boolean hasEntry` + - entry body when `hasEntry=true`, same shape as `EarningsCenterComposer` + +## Categories + +- `daily_gift` +- `games` +- `achievements` +- `marketplace` +- `hc_payday` +- `level_progress` +- `donations` +- `bonus_bag` +- `mystery_boxes` +- `club_job` + +## Reward Types + +- `credits` +- `pixels` +- `points` +- `badge` +- `item` +- `hc_days` + +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. + +## Result Status + +- `success` +- `disabled` +- `unknown_category` +- `already_claimed` +- `no_reward` +- `error` + +The client must not send reward amounts. Claim eligibility and rewards are always server authoritative. From 766d8d67d3067d57cd2951120626a3fab5a519a9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 21:14:35 +0200 Subject: [PATCH 4/5] feat(earnings): integrate native reward sources --- Database Updates/012_earnings_center.sql | 12 + .../src/main/java/com/eu/habbo/Emulator.java | 1 + .../earnings/EarningsCenterManager.java | 218 ++++++++++++++++-- .../earnings/EarningsCenterManagerTest.java | 79 +++++++ docs/earnings-packet-contract.md | 5 + .../2026-06-15-earnings-center-design.md | 4 + 6 files changed, 302 insertions(+), 17 deletions(-) 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 From 22b05b4e525109b0661795f5b394057c11ab01e7 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 21:49:45 +0200 Subject: [PATCH 5/5] feat(earnings): gate rewards by user progress --- Database Updates/012_earnings_center.sql | 9 +++- .../src/main/java/com/eu/habbo/Emulator.java | 6 ++- .../earnings/EarningsCenterManager.java | 45 +++++++++++++++++-- docs/earnings-packet-contract.md | 2 + .../2026-06-15-earnings-center-design.md | 3 ++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/Database Updates/012_earnings_center.sql b/Database Updates/012_earnings_center.sql index f3063162..7cc8d557 100644 --- a/Database Updates/012_earnings_center.sql +++ b/Database Updates/012_earnings_center.sql @@ -117,11 +117,16 @@ INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES 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.achievements.native.enabled', '1', 'Use achievement score thresholds for achievements earnings claims.'), ('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.level_progress.native.enabled', '1', 'Use talent track levels for level progress earnings claims.'), ('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.'); + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES +('earnings.achievements.min_score', '1', 'Minimum achievement score required before achievements earnings can be claimed.'), +('earnings.achievements.score.step', '100', 'Achievement score bucket size used to prevent repeated claims for the same progress band.'), +('earnings.level_progress.min_level', '1', 'Minimum citizenship/helper talent level required before level progress earnings can be claimed.'); diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index e7b7ad45..52c7f8b1 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -517,8 +517,12 @@ 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"); + 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() { 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 58f589e2..88ed4ebc 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 @@ -95,7 +95,11 @@ public class EarningsCenterManager { return new EarningsClaimResult(category, EarningsClaimResult.Status.NO_REWARD, buildEntry(habbo, userId, category, now)); } - String periodKey = periodKey(now, definition.cooldownSeconds()); + 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)) { @@ -143,7 +147,11 @@ public class EarningsCenterManager { return new EarningsEntry(category, true, claimable, 0, definition.rewards()); } - String periodKey = periodKey(now, definition.cooldownSeconds()); + 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); @@ -186,6 +194,25 @@ public class EarningsCenterManager { 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 rewards, String type, int amount, int pointsType) { int clampedAmount = Math.min(Math.max(0, amount), MAX_CONFIGURED_REWARD); if (clampedAmount > 0) { @@ -229,7 +256,19 @@ public class EarningsCenterManager { return (int) (this.clock.instant().getEpochSecond()); } - private String periodKey(int now, int cooldownSeconds) { + 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); } diff --git a/docs/earnings-packet-contract.md b/docs/earnings-packet-contract.md index 66d24f12..604bb85c 100644 --- a/docs/earnings-packet-contract.md +++ b/docs/earnings-packet-contract.md @@ -82,6 +82,8 @@ For `points`, `pointsType` carries the currency type. For `badge`, `data` carrie - `marketplace`: sold marketplace offers waiting for payout - `hc_payday`: unclaimed rows in `logs_hc_payday` +- `achievements`: configured rewards gated by achievement score buckets +- `level_progress`: configured rewards gated by citizenship/helper talent level ## Result Status 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 580bf284..07b13a2f 100644 --- a/docs/superpowers/specs/2026-06-15-earnings-center-design.md +++ b/docs/superpowers/specs/2026-06-15-earnings-center-design.md @@ -67,6 +67,7 @@ Add emulator settings with safe defaults: 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. +Achievements and level progress use native eligibility by default: achievement score buckets and talent-track levels decide when the configured reward may be claimed. ## Packet Contract @@ -94,6 +95,8 @@ Composer format is intentionally simple and renderer-friendly: category key, ena - `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. +- Achievement claims can be scoped to score buckets via `earnings.achievements.score.step`. +- Level progress claims can be scoped to the current highest citizenship/helper level. ## Tests