From 36a06647f0dc3e3fe7f920a90594b617ea59c49e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 19:51:36 +0200 Subject: [PATCH 01/10] fix(modtool): enforce staff target rank ceilings --- .../habbohotel/modtool/ModToolManager.java | 40 ++++++++++++++----- .../habbohotel/modtool/ModToolSanctions.java | 4 ++ .../ModToolIssueDefaultSanctionEvent.java | 4 ++ .../ModToolPermissionContractTest.java | 28 ++++++++++++- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java index 9883ec7a..efa38b8b 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java @@ -3,6 +3,7 @@ package com.eu.habbo.habbohotel.modtool; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.gameclients.GameClient; import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.permissions.Rank; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.rooms.RoomState; import com.eu.habbo.habbohotel.users.Habbo; @@ -423,12 +424,16 @@ public class ModToolManager { } public void kick(Habbo moderator, Habbo target, String message) { - if (moderator.hasPermission(Permission.ACC_SUPPORTTOOL) && !target.hasPermission(Permission.ACC_UNKICKABLE)) { - if (target.getHabboInfo().getCurrentRoom() != null) { - Emulator.getGameEnvironment().getRoomManager().leaveRoom(target, target.getHabboInfo().getCurrentRoom()); - } - this.alert(moderator, target, message, SupportUserAlertedReason.KICKED); + if (moderator == null || target == null || !moderator.hasPermission(Permission.ACC_SUPPORTTOOL) || + target.hasPermission(Permission.ACC_UNKICKABLE) || + !canModerateTarget(moderator, target.getHabboInfo().getId())) { + return; } + + if (target.getHabboInfo().getCurrentRoom() != null) { + Emulator.getGameEnvironment().getRoomManager().leaveRoom(target, target.getHabboInfo().getCurrentRoom()); + } + this.alert(moderator, target, message, SupportUserAlertedReason.KICKED); } public List ban(int targetUserId, Habbo moderator, String reason, int duration, ModToolBanType type, int cfhTopic) { @@ -443,7 +448,7 @@ public class ModToolManager { return bans; } - if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) { + if (!canModerateTarget(moderator, targetUserId)) { return bans; } @@ -468,7 +473,7 @@ public class ModToolManager { if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) { for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(ban.ip)) { - if (h.getHabboInfo().getRank().getId() >= moderator.getHabboInfo().getRank().getId()) continue; + if (!canModerateTarget(moderator, h.getHabboInfo().getId())) continue; ban = new ModToolBan(h.getHabboInfo().getId(), h != null ? h.getHabboInfo().getIpLogin() : "offline", h != null ? h.getClient().getMachineId() : "offline", moderator.getHabboInfo().getId(), Emulator.getIntUnixTimestamp() + duration, reason, type, cfhTopic); Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); @@ -480,7 +485,7 @@ public class ModToolManager { if ((type == ModToolBanType.MACHINE || type == ModToolBanType.SUPER) && target != null && !ban.machineId.equals("offline")) { for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithMachineId(ban.machineId)) { - if (h.getHabboInfo().getRank().getId() >= moderator.getHabboInfo().getRank().getId()) continue; + if (!canModerateTarget(moderator, h.getHabboInfo().getId())) continue; ban = new ModToolBan(h.getHabboInfo().getId(), h != null ? h.getHabboInfo().getIpLogin() : "offline", h != null ? h.getClient().getMachineId() : "offline", moderator.getHabboInfo().getId(), Emulator.getIntUnixTimestamp() + duration, reason, type, cfhTopic); Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); @@ -501,10 +506,27 @@ public class ModToolManager { if (targetInfo == null) return false; - return targetInfo.getRank().getId() < moderator.getHabboInfo().getRank().getId(); + int moderatorRankId = moderator.getHabboInfo().getRank().getId(); + int targetRankId = targetInfo.getRank().getId(); + + return targetRankId < moderatorRankId || isCoreRank(moderatorRankId) && targetRankId <= moderatorRankId; + } + + private static boolean isCoreRank(int rankId) { + int highestRankId = 0; + for (Rank rank : Emulator.getGameEnvironment().getPermissionsManager().getAllRanks()) { + highestRankId = Math.max(highestRankId, rank.getId()); + } + + return highestRankId > 0 && rankId >= highestRankId; } public void roomAction(Room room, Habbo moderator, boolean kickUsers, boolean lockDoor, boolean changeTitle) { + if (room == null || moderator == null || !moderator.hasPermission(Permission.ACC_SUPPORTTOOL) || + !canModerateTarget(moderator, room.getOwnerId())) { + return; + } + SupportRoomActionEvent roomActionEvent = new SupportRoomActionEvent(moderator, room, kickUsers, lockDoor, changeTitle); Emulator.getPluginManager().fireEvent(roomActionEvent); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java index 14b2dfeb..85275631 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java @@ -128,6 +128,10 @@ public class ModToolSanctions { } public void run(int habboId, Habbo self, int sanctionLevel, int cfhTopic, String reason, int tradeLockedUntil, boolean isMuted, int muteDuration) { + if (!ModToolManager.canModerateTarget(self, habboId)) { + return; + } + sanctionLevel++; ModToolSanctionLevelItem sanctionLevelItem = getSanctionLevelItem(sanctionLevel); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java index b237c5f5..2b0789f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java @@ -21,6 +21,10 @@ public class ModToolIssueDefaultSanctionEvent extends MessageHandler { ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(issueId); + if (issue == null) { + return; + } + if (issue.modId == this.client.getHabbo().getHabboInfo().getId()) { CfhTopic modToolCategory = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(category); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java index 460da889..9ab0ff8d 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java @@ -45,7 +45,7 @@ class ModToolPermissionContractTest { } @Test - void modToolSanctionsCannotTargetSameOrHigherRanks() throws Exception { + void modToolSanctionsCannotTargetPeerRanksUnlessOperatorIsCoreRank() throws Exception { Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); for (String handler : List.of( @@ -60,6 +60,30 @@ class ModToolPermissionContractTest { String manager = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java")); assertTrue(manager.contains("!canModerateTarget(moderator, target.getHabboInfo().getId())"), - "ModToolManager.alert must refuse alerts/warnings against same-or-higher-rank targets"); + "ModToolManager.alert must refuse alerts/warnings against protected targets"); + assertTrue(manager.contains("targetRankId < moderatorRankId"), + "non-core moderators must only target lower-ranked users"); + assertTrue(manager.contains("isCoreRank(moderatorRankId) && targetRankId <= moderatorRankId"), + "highest/core moderators should be allowed to target peer ranks"); + assertTrue(manager.contains("private static boolean isCoreRank(int rankId)"), + "core-rank detection should be centralized in ModToolManager"); + } + + @Test + void managerEntryPointsShareTargetAndRoomOwnerGuards() throws Exception { + String manager = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java")); + String sanctions = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/ModToolSanctions.java")); + String defaultSanction = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueDefaultSanctionEvent.java")); + + assertTrue(manager.contains("!canModerateTarget(moderator, targetUserId)"), + "ModToolManager.ban must use the central target-rank guard for offline and online users"); + assertTrue(manager.contains("!canModerateTarget(moderator, h.getHabboInfo().getId())"), + "IP and machine fan-out bans must skip protected peer-or-higher ranked sessions"); + assertTrue(manager.contains("!canModerateTarget(moderator, room.getOwnerId())"), + "ModToolManager.roomAction must refuse mutations on rooms owned by protected ranks"); + assertTrue(sanctions.contains("!ModToolManager.canModerateTarget(self, habboId)"), + "ModToolSanctions.run must guard every sanction path before writing or applying it"); + assertTrue(defaultSanction.contains("if (issue == null)"), + "default sanctions must tolerate stale or missing ticket ids"); } } From 8ba9132e7e9bf709691519c1c2e5ebdc350bacd3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 19:54:34 +0200 Subject: [PATCH 02/10] fix(modtool): bound staff supplied messages --- .../incoming/modtool/ModToolAlertEvent.java | 11 +++++++-- .../incoming/modtool/ModToolInputGuard.java | 16 +++++++++++++ .../incoming/modtool/ModToolKickEvent.java | 9 +++++++- .../modtool/ModToolRoomAlertEvent.java | 7 +++++- .../modtool/ModToolSanctionAlertEvent.java | 6 ++++- .../modtool/ModToolSanctionBanEvent.java | 8 +++++-- .../modtool/ModToolSanctionMuteEvent.java | 6 ++++- .../ModToolSanctionTradeLockEvent.java | 6 ++++- .../incoming/modtool/ModToolWarnEvent.java | 11 +++++++-- .../modtool/ModToolInputGuardTest.java | 23 +++++++++++++++++++ .../ModToolPermissionContractTest.java | 23 +++++++++++++++++++ 11 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java index 09b4ebb3..c2b6820f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolAlertEvent.java @@ -11,10 +11,17 @@ public class ModToolAlertEvent extends MessageHandler { @Override public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { - Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(this.packet.readInt()); + int userId = this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + + Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (alertedUser != null) - Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, this.packet.readString(), SupportUserAlertedReason.ALERT); + Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, message, SupportUserAlertedReason.ALERT); } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.kick").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java new file mode 100644 index 00000000..f401a99a --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuard.java @@ -0,0 +1,16 @@ +package com.eu.habbo.messages.incoming.modtool; + +final class ModToolInputGuard { + static final int MAX_MESSAGE_LENGTH = 1000; + + private ModToolInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isSafeMessage(String value) { + return value != null && !value.isEmpty() && value.length() <= MAX_MESSAGE_LENGTH; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java index 6491a4bf..da01445a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolKickEvent.java @@ -18,6 +18,13 @@ public class ModToolKickEvent extends MessageHandler { return; } - Emulator.getGameEnvironment().getModToolManager().kick(this.client.getHabbo(), Emulator.getGameEnvironment().getHabboManager().getHabbo(this.packet.readInt()), this.packet.readString()); + int userId = this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + + Emulator.getGameEnvironment().getModToolManager().kick(this.client.getHabbo(), Emulator.getGameEnvironment().getHabboManager().getHabbo(userId), message); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java index 99f0bc0a..db84edf3 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRoomAlertEvent.java @@ -11,10 +11,15 @@ public class ModToolRoomAlertEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); if (room != null) { - room.alert(this.packet.readString()); + room.alert(message); } } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.roomalert").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java index cb609001..c37f1f52 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java @@ -21,9 +21,13 @@ public class ModToolSanctionAlertEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int cfhTopic = this.packet.readInt(); + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java index 5a2c8afa..d23f943a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java @@ -30,13 +30,17 @@ public class ModToolSanctionBanEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int cfhTopic = this.packet.readInt(); int banType = this.packet.readInt(); this.packet.readBoolean(); int duration = 0; + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + switch (banType) { case BAN_18_HOURS: duration = 18 * 60 * 60; @@ -79,4 +83,4 @@ public class ModToolSanctionBanEvent extends MessageHandler { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.ban").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java index 16a2bbdd..e5f91ab2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java @@ -23,9 +23,13 @@ public class ModToolSanctionMuteEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int cfhTopic = this.packet.readInt(); + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java index 9f666868..b49134f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java @@ -21,10 +21,14 @@ public class ModToolSanctionTradeLockEvent extends MessageHandler { @Override public void handle() throws Exception { int userId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolInputGuard.normalize(this.packet.readString()); int duration = this.packet.readInt(); int cfhTopic = this.packet.readInt(); + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java index a8dae64f..06c118b2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolWarnEvent.java @@ -16,10 +16,17 @@ public class ModToolWarnEvent extends MessageHandler { @Override public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { - Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(this.packet.readInt()); + int userId = this.packet.readInt(); + String message = ModToolInputGuard.normalize(this.packet.readString()); + + if (!ModToolInputGuard.isSafeMessage(message)) { + return; + } + + Habbo alertedUser = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (alertedUser != null) - Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, this.packet.readString(), SupportUserAlertedReason.CAUTION); + Emulator.getGameEnvironment().getModToolManager().alert(this.client.getHabbo(), alertedUser, message, SupportUserAlertedReason.CAUTION); } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.kick").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java new file mode 100644 index 00000000..214d2087 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolInputGuardTest.java @@ -0,0 +1,23 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +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 ModToolInputGuardTest { + @Test + void normalizesNullableMessages() { + assertEquals("", ModToolInputGuard.normalize(null)); + assertEquals("warn", ModToolInputGuard.normalize(" warn ")); + } + + @Test + void staffMessagesMustBeNonEmptyAndBounded() { + assertFalse(ModToolInputGuard.isSafeMessage(null)); + assertFalse(ModToolInputGuard.isSafeMessage("")); + assertTrue(ModToolInputGuard.isSafeMessage("a".repeat(ModToolInputGuard.MAX_MESSAGE_LENGTH))); + assertFalse(ModToolInputGuard.isSafeMessage("a".repeat(ModToolInputGuard.MAX_MESSAGE_LENGTH + 1))); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java index 9ab0ff8d..ba03fc67 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolPermissionContractTest.java @@ -86,4 +86,27 @@ class ModToolPermissionContractTest { assertTrue(defaultSanction.contains("if (issue == null)"), "default sanctions must tolerate stale or missing ticket ids"); } + + @Test + void staffSuppliedModToolMessagesAreBounded() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ModToolAlertEvent.java", + "ModToolWarnEvent.java", + "ModToolKickEvent.java", + "ModToolRoomAlertEvent.java", + "ModToolSanctionAlertEvent.java", + "ModToolSanctionBanEvent.java", + "ModToolSanctionMuteEvent.java", + "ModToolSanctionTradeLockEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolInputGuard.normalize"), + handler + " must normalize staff-supplied text before use"); + assertTrue(source.contains("ModToolInputGuard.isSafeMessage"), + handler + " must reject empty or oversized staff-supplied text"); + } + } } From c98261d8c3bd373998b6b14be2687c1bdfb6ee25 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:13:42 +0200 Subject: [PATCH 03/10] fix(forums): validate guild forum inputs --- .../guilds/forums/GuildForumInputGuard.java | 37 +++++++++++ .../forums/GuildForumMarkAsReadEvent.java | 8 +++ .../GuildForumModerateMessageEvent.java | 10 ++- .../forums/GuildForumModerateThreadEvent.java | 9 ++- .../forums/GuildForumPostThreadEvent.java | 11 +++- .../forums/GuildForumThreadUpdateEvent.java | 7 ++- .../guilds/forums/GuildForumThreadsEvent.java | 5 ++ .../GuildForumThreadsMessagesEvent.java | 8 ++- .../forums/GuildForumUpdateSettingsEvent.java | 11 +++- .../GuildForumInputGuardContractTest.java | 62 +++++++++++++++++++ .../forums/GuildForumInputGuardTest.java | 41 ++++++++++++ 11 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java new file mode 100644 index 00000000..36f8876f --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuard.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +final class GuildForumInputGuard { + static final int MAX_PAGE_LIMIT = 50; + static final int MAX_MARK_READ_BATCH = 50; + + private GuildForumInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isPositiveId(int id) { + return id > 0; + } + + static boolean isValidPage(int index, int limit) { + return index >= 0 && limit > 0 && limit <= MAX_PAGE_LIMIT; + } + + static boolean isValidMarkReadBatch(int count) { + return count > 0 && count <= MAX_MARK_READ_BATCH; + } + + static boolean isSettingsState(int state) { + return state >= 0 && state <= 3; + } + + static boolean isThreadModerationState(int state) { + return state == 1 || state == 10 || state == 20; + } + + static boolean isMessageModerationState(int state) { + return state == 1 || state == 10 || state == 20; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java index ace83232..fa83390b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java @@ -24,11 +24,19 @@ public class GuildForumMarkAsReadEvent extends MessageHandler { int userId = this.client.getHabbo().getHabboInfo().getId(); int timestamp = Emulator.getIntUnixTimestamp(); + if (!GuildForumInputGuard.isValidMarkReadBatch(count)) { + return; + } + for (int i = 0; i < count; i++) { int guildId = this.packet.readInt(); this.packet.readInt(); // messageId (not used, we track by timestamp) this.packet.readBoolean(); // isRead + if (!GuildForumInputGuard.isPositiveId(guildId)) { + continue; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "INSERT INTO `guild_forum_views` (`user_id`, `guild_id`, `timestamp`) VALUES (?, ?, ?) " + "ON DUPLICATE KEY UPDATE `timestamp` = ?" diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java index 51b9ec0b..5a87f77c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java @@ -28,6 +28,14 @@ public class GuildForumModerateMessageEvent extends MessageHandler { int messageId = packet.readInt(); int state = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isPositiveId(threadId) || + !GuildForumInputGuard.isPositiveId(messageId) || + !GuildForumInputGuard.isMessageModerationState(state)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -85,4 +93,4 @@ public class GuildForumModerateMessageEvent extends MessageHandler { } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java index 3fa8905b..65e3442a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java @@ -36,6 +36,13 @@ public class GuildForumModerateThreadEvent extends MessageHandler { int threadId = packet.readInt(); int state = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isPositiveId(threadId) || + !GuildForumInputGuard.isThreadModerationState(state)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -108,4 +115,4 @@ public class GuildForumModerateThreadEvent extends MessageHandler { LOGGER.error("Failed to delete thread " + threadId, e); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java index 5ca0d1b4..465360a9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java @@ -25,8 +25,13 @@ public class GuildForumPostThreadEvent extends MessageHandler { public void handle() throws Exception { int guildId = this.packet.readInt(); int threadId = this.packet.readInt(); - String subject = Emulator.getGameEnvironment().getWordFilter().filter(this.packet.readString(), this.client.getHabbo()); - String message = Emulator.getGameEnvironment().getWordFilter().filter(this.packet.readString(), this.client.getHabbo()); + String subject = Emulator.getGameEnvironment().getWordFilter().filter(GuildForumInputGuard.normalize(this.packet.readString()), this.client.getHabbo()); + String message = Emulator.getGameEnvironment().getWordFilter().filter(GuildForumInputGuard.normalize(this.packet.readString()), this.client.getHabbo()); + + if (!GuildForumInputGuard.isPositiveId(guildId) || threadId < 0) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); @@ -108,4 +113,4 @@ public class GuildForumPostThreadEvent extends MessageHandler { this.client.sendResponse(new ConnectionErrorComposer(500)); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java index 741a818f..0d8eae0b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java @@ -27,6 +27,11 @@ public class GuildForumThreadUpdateEvent extends MessageHandler { boolean isPinned = this.packet.readBoolean(); boolean isLocked = this.packet.readBoolean(); + if (!GuildForumInputGuard.isPositiveId(guildId) || !GuildForumInputGuard.isPositiveId(threadId)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -71,4 +76,4 @@ public class GuildForumThreadUpdateEvent extends MessageHandler { this.client.sendResponse(new GuildForumThreadsComposer(guild, 0)); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java index 8cde29ee..b4c4cfe6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java @@ -22,6 +22,11 @@ public class GuildForumThreadsEvent extends MessageHandler { int guildId = packet.readInt(); int index = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || index < 0) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); if (guild == null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java index 9d490352..909909d0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java @@ -29,6 +29,12 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler { int index = packet.readInt(); // 40 int limit = packet.readInt(); // 20 + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isPositiveId(threadId) || + !GuildForumInputGuard.isValidPage(index, limit)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -59,4 +65,4 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler { this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_ACCESS_DENIED.key).compose()); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java index 11916a7b..ed9748d8 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java @@ -23,6 +23,15 @@ public class GuildForumUpdateSettingsEvent extends MessageHandler { int postThreads = packet.readInt(); int modForum = packet.readInt(); + if (!GuildForumInputGuard.isPositiveId(guildId) || + !GuildForumInputGuard.isSettingsState(canRead) || + !GuildForumInputGuard.isSettingsState(postMessages) || + !GuildForumInputGuard.isSettingsState(postThreads) || + !GuildForumInputGuard.isSettingsState(modForum)) { + this.client.sendResponse(new ConnectionErrorComposer(400)); + return; + } + Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); if (guild == null) { @@ -48,4 +57,4 @@ public class GuildForumUpdateSettingsEvent extends MessageHandler { this.client.sendResponse(new GuildForumDataComposer(guild, this.client.getHabbo())); } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java new file mode 100644 index 00000000..d7c32f8b --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardContractTest.java @@ -0,0 +1,62 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuildForumInputGuardContractTest { + @Test + void forumHandlersValidateClientProvidedIds() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/guilds/forums"); + + for (String handler : List.of( + "GuildForumPostThreadEvent.java", + "GuildForumModerateMessageEvent.java", + "GuildForumModerateThreadEvent.java", + "GuildForumThreadUpdateEvent.java", + "GuildForumThreadsEvent.java", + "GuildForumThreadsMessagesEvent.java", + "GuildForumMarkAsReadEvent.java", + "GuildForumUpdateSettingsEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("GuildForumInputGuard.isPositiveId"), + handler + " must reject zero or negative client-provided ids"); + } + } + + @Test + void forumHandlersBoundExpensiveClientInputs() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/guilds/forums"); + + String messages = Files.readString(base.resolve("GuildForumThreadsMessagesEvent.java")); + String markRead = Files.readString(base.resolve("GuildForumMarkAsReadEvent.java")); + String settings = Files.readString(base.resolve("GuildForumUpdateSettingsEvent.java")); + String moderateThread = Files.readString(base.resolve("GuildForumModerateThreadEvent.java")); + String moderateMessage = Files.readString(base.resolve("GuildForumModerateMessageEvent.java")); + + assertTrue(messages.contains("GuildForumInputGuard.isValidPage(index, limit)"), + "thread message reads must bound index/limit before fetching comments"); + assertTrue(markRead.contains("GuildForumInputGuard.isValidMarkReadBatch(count)"), + "mark-as-read must bound the client-provided batch count before DB writes"); + assertTrue(settings.contains("GuildForumInputGuard.isSettingsState"), + "forum settings must reject unknown SettingsState values"); + assertTrue(moderateThread.contains("GuildForumInputGuard.isThreadModerationState(state)"), + "thread moderation must reject unknown ForumThreadState values"); + assertTrue(moderateMessage.contains("GuildForumInputGuard.isMessageModerationState(state)"), + "message moderation must reject unknown ForumThreadState values"); + } + + @Test + void forumPostsNormalizeTextBeforeFiltering() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java")); + + assertTrue(source.contains("GuildForumInputGuard.normalize(this.packet.readString())"), + "forum post subject and body should be normalized before word filtering and length checks"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java new file mode 100644 index 00000000..47189ba6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumInputGuardTest.java @@ -0,0 +1,41 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +import org.junit.jupiter.api.Test; + +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 GuildForumInputGuardTest { + @Test + void normalizesNullableText() { + assertEquals("", GuildForumInputGuard.normalize(null)); + assertEquals("hello", GuildForumInputGuard.normalize(" hello ")); + } + + @Test + void validatesIdsAndPaging() { + assertFalse(GuildForumInputGuard.isPositiveId(0)); + assertTrue(GuildForumInputGuard.isPositiveId(1)); + assertFalse(GuildForumInputGuard.isValidPage(-1, 20)); + assertFalse(GuildForumInputGuard.isValidPage(0, 0)); + assertTrue(GuildForumInputGuard.isValidPage(0, GuildForumInputGuard.MAX_PAGE_LIMIT)); + assertFalse(GuildForumInputGuard.isValidPage(0, GuildForumInputGuard.MAX_PAGE_LIMIT + 1)); + } + + @Test + void validatesBatchAndStates() { + assertFalse(GuildForumInputGuard.isValidMarkReadBatch(0)); + assertTrue(GuildForumInputGuard.isValidMarkReadBatch(GuildForumInputGuard.MAX_MARK_READ_BATCH)); + assertFalse(GuildForumInputGuard.isValidMarkReadBatch(GuildForumInputGuard.MAX_MARK_READ_BATCH + 1)); + + assertTrue(GuildForumInputGuard.isSettingsState(0)); + assertTrue(GuildForumInputGuard.isSettingsState(3)); + assertFalse(GuildForumInputGuard.isSettingsState(4)); + + assertTrue(GuildForumInputGuard.isThreadModerationState(20)); + assertFalse(GuildForumInputGuard.isThreadModerationState(999)); + assertTrue(GuildForumInputGuard.isMessageModerationState(10)); + assertFalse(GuildForumInputGuard.isMessageModerationState(0)); + } +} From 044d1141cdbb86c4d9da1091298601ee29863fd6 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:01:34 +0200 Subject: [PATCH 04/10] fix(modtool): validate report payloads --- .../modtool/ModToolReportInputGuard.java | 30 ++++++++ .../incoming/modtool/ReportBullyEvent.java | 6 +- .../incoming/modtool/ReportCommentEvent.java | 10 ++- .../incoming/modtool/ReportEvent.java | 20 ++++-- .../modtool/ReportFriendPrivateChatEvent.java | 20 +++++- .../incoming/modtool/ReportPhotoEvent.java | 6 ++ .../incoming/modtool/ReportThreadEvent.java | 9 ++- .../ModToolReportInputContractTest.java | 69 +++++++++++++++++++ .../modtool/ModToolReportInputGuardTest.java | 37 ++++++++++ 9 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java new file mode 100644 index 00000000..f265096d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuard.java @@ -0,0 +1,30 @@ +package com.eu.habbo.messages.incoming.modtool; + +final class ModToolReportInputGuard { + static final int MAX_REPORT_MESSAGE_LENGTH = 1000; + static final int MAX_PRIVATE_CHAT_LOGS = 100; + static final int MAX_PRIVATE_CHAT_MESSAGE_LENGTH = 500; + + private ModToolReportInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isValidReportMessage(String value) { + return value != null && !value.isEmpty() && value.length() <= MAX_REPORT_MESSAGE_LENGTH; + } + + static boolean isValidChatLogMessage(String value) { + return value != null && value.length() <= MAX_PRIVATE_CHAT_MESSAGE_LENGTH; + } + + static boolean isValidPrivateChatLogCount(int count) { + return count > 0 && count <= MAX_PRIVATE_CHAT_LOGS; + } + + static boolean isPositiveId(int id) { + return id > 0; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java index f2207f2c..7943cbc2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java @@ -14,7 +14,7 @@ import java.util.ArrayList; public class ReportBullyEvent extends MessageHandler { @Override public void handle() throws Exception { - if (this.client.getHabbo().getHabboStats().allowTalk()) { + if (!this.client.getHabbo().getHabboStats().allowTalk()) { this.client.sendResponse(new HelperRequestDisabledComposer()); return; } @@ -22,7 +22,9 @@ public class ReportBullyEvent extends MessageHandler { int userId = this.packet.readInt(); int roomId = this.packet.readInt(); - if (userId == this.client.getHabbo().getHabboInfo().getId()) { + if (!ModToolReportInputGuard.isPositiveId(userId) || + !ModToolReportInputGuard.isPositiveId(roomId) || + userId == this.client.getHabbo().getHabboInfo().getId()) { return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java index b21849ba..85673001 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportCommentEvent.java @@ -17,7 +17,15 @@ public class ReportCommentEvent extends MessageHandler { int threadId = this.packet.readInt(); int commentId = this.packet.readInt(); int topicId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); + + if (!ModToolReportInputGuard.isPositiveId(groupId) || + !ModToolReportInputGuard.isPositiveId(threadId) || + !ModToolReportInputGuard.isPositiveId(commentId) || + !ModToolReportInputGuard.isPositiveId(topicId) || + !ModToolReportInputGuard.isValidReportMessage(message)) { + return; + } CfhTopic topic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topicId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java index 99c8ba6c..aabb2dbc 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java @@ -21,12 +21,26 @@ public class ReportEvent extends MessageHandler { return; } - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); int topic = this.packet.readInt(); int userId = this.packet.readInt(); int roomId = this.packet.readInt(); this.packet.readInt(); + if (!ModToolReportInputGuard.isValidReportMessage(message) || + topic <= 0 || + (userId != -1 && !ModToolReportInputGuard.isPositiveId(userId)) || + !ModToolReportInputGuard.isPositiveId(roomId) || + userId == this.client.getHabbo().getHabboInfo().getId()) { + return; + } + + CfhTopic cfhTopic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topic); + + if (cfhTopic == null) { + return; + } + Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); List issues = Emulator.getGameEnvironment().getModToolManager().openTicketsForHabbo(this.client.getHabbo()); if (!issues.isEmpty()) { @@ -35,8 +49,6 @@ public class ReportEvent extends MessageHandler { return; } - CfhTopic cfhTopic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topic); - if (userId != -1) { Habbo reported = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); @@ -117,4 +129,4 @@ public class ReportEvent extends MessageHandler { } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java index 63e1ba43..4840679d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportFriendPrivateChatEvent.java @@ -21,12 +21,20 @@ public class ReportFriendPrivateChatEvent extends MessageHandler { return; } - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); int category = this.packet.readInt(); int userId = this.packet.readInt(); int count = this.packet.readInt(); ArrayList chatLogs = new ArrayList<>(); + if (!ModToolReportInputGuard.isValidReportMessage(message) || + category <= 0 || + !ModToolReportInputGuard.isPositiveId(userId) || + userId == this.client.getHabbo().getHabboInfo().getId() || + !ModToolReportInputGuard.isValidPrivateChatLogCount(count)) { + return; + } + HabboInfo info; Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (target != null) { @@ -37,11 +45,17 @@ public class ReportFriendPrivateChatEvent extends MessageHandler { if (info == null) return; - for (int i = 0; i < Math.min(count, 100); i++) { + for (int i = 0; i < count; i++) { int chatUserId = this.packet.readInt(); String username = this.packet.readInt() == info.getId() ? info.getUsername() : this.client.getHabbo().getHabboInfo().getUsername(); + String chatMessage = ModToolReportInputGuard.normalize(this.packet.readString()); - chatLogs.add(new ModToolChatLog(0, chatUserId, username, this.packet.readString())); + if (!ModToolReportInputGuard.isPositiveId(chatUserId) || + !ModToolReportInputGuard.isValidChatLogMessage(chatMessage)) { + return; + } + + chatLogs.add(new ModToolChatLog(0, chatUserId, username, chatMessage)); } ModToolIssue issue = new ModToolIssue(this.client.getHabbo().getHabboInfo().getId(), this.client.getHabbo().getHabboInfo().getUsername(), userId, info.getUsername(), 0, message, ModToolTicketType.IM); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java index fc225fb5..fc6e1f04 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportPhotoEvent.java @@ -28,6 +28,12 @@ public class ReportPhotoEvent extends MessageHandler { int topicId = this.packet.readInt(); int itemId = this.packet.readInt(); + if (!ModToolReportInputGuard.isPositiveId(roomId) || + !ModToolReportInputGuard.isPositiveId(topicId) || + !ModToolReportInputGuard.isPositiveId(itemId)) { + return; + } + CfhTopic topic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topicId); if (topic == null) return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java index 50bcab89..b1e0b8b1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ReportThreadEvent.java @@ -16,7 +16,14 @@ public class ReportThreadEvent extends MessageHandler { int groupId = this.packet.readInt(); int threadId = this.packet.readInt(); int topicId = this.packet.readInt(); - String message = this.packet.readString(); + String message = ModToolReportInputGuard.normalize(this.packet.readString()); + + if (!ModToolReportInputGuard.isPositiveId(groupId) || + !ModToolReportInputGuard.isPositiveId(threadId) || + !ModToolReportInputGuard.isPositiveId(topicId) || + !ModToolReportInputGuard.isValidReportMessage(message)) { + return; + } CfhTopic topic = Emulator.getGameEnvironment().getModToolManager().getCfhTopic(topicId); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java new file mode 100644 index 00000000..75f26644 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputContractTest.java @@ -0,0 +1,69 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModToolReportInputContractTest { + @Test + void reportHandlersNormalizeAndBoundFreeText() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ReportEvent.java", + "ReportFriendPrivateChatEvent.java", + "ReportCommentEvent.java", + "ReportThreadEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolReportInputGuard.normalize"), + handler + " must normalize report text before persistence or staff broadcast"); + assertTrue(source.contains("ModToolReportInputGuard.isValidReportMessage"), + handler + " must reject empty or oversized report text"); + } + } + + @Test + void reportHandlersRejectInvalidIdsAndCounts() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ReportEvent.java", + "ReportFriendPrivateChatEvent.java", + "ReportCommentEvent.java", + "ReportThreadEvent.java", + "ReportBullyEvent.java", + "ReportPhotoEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolReportInputGuard.isPositiveId"), + handler + " must reject zero or negative ids supplied by the client"); + } + + String privateChat = Files.readString(base.resolve("ReportFriendPrivateChatEvent.java")); + assertTrue(privateChat.contains("ModToolReportInputGuard.isValidPrivateChatLogCount(count)"), + "private chat reports must reject negative or oversized client-provided chatlog counts"); + } + + @Test + void reportEventValidatesTopicBeforeUsingReply() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ReportEvent.java")); + + assertTrue(source.indexOf("if (cfhTopic == null)") < source.indexOf("cfhTopic.reply"), + "ReportEvent must reject unknown topics before dereferencing the reply text"); + } + + @Test + void bullyReportUsesSameMutedUserGateAsNormalReports() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ReportBullyEvent.java")); + + assertTrue(source.contains("if (!this.client.getHabbo().getHabboStats().allowTalk())"), + "bully reports must reject muted users instead of rejecting users who can talk"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java new file mode 100644 index 00000000..91e49a46 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolReportInputGuardTest.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +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 ModToolReportInputGuardTest { + @Test + void normalizesNullableMessages() { + assertEquals("", ModToolReportInputGuard.normalize(null)); + assertEquals("report", ModToolReportInputGuard.normalize(" report ")); + } + + @Test + void reportMessagesMustBeNonEmptyAndBounded() { + assertFalse(ModToolReportInputGuard.isValidReportMessage("")); + assertFalse(ModToolReportInputGuard.isValidReportMessage(null)); + assertTrue(ModToolReportInputGuard.isValidReportMessage("a".repeat(ModToolReportInputGuard.MAX_REPORT_MESSAGE_LENGTH))); + assertFalse(ModToolReportInputGuard.isValidReportMessage("a".repeat(ModToolReportInputGuard.MAX_REPORT_MESSAGE_LENGTH + 1))); + } + + @Test + void privateChatLogCountsAreBounded() { + assertFalse(ModToolReportInputGuard.isValidPrivateChatLogCount(0)); + assertTrue(ModToolReportInputGuard.isValidPrivateChatLogCount(ModToolReportInputGuard.MAX_PRIVATE_CHAT_LOGS)); + assertFalse(ModToolReportInputGuard.isValidPrivateChatLogCount(ModToolReportInputGuard.MAX_PRIVATE_CHAT_LOGS + 1)); + } + + @Test + void idsMustBePositive() { + assertFalse(ModToolReportInputGuard.isPositiveId(0)); + assertFalse(ModToolReportInputGuard.isPositiveId(-1)); + assertTrue(ModToolReportInputGuard.isPositiveId(1)); + } +} From 916ef7af3ade7760b21042fa1d1b897fcc873133 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:07:24 +0200 Subject: [PATCH 05/10] fix(modtool): guard ticket lifecycle inputs --- .../modtool/ModToolCloseTicketEvent.java | 6 +- .../modtool/ModToolIssueChangeTopicEvent.java | 10 ++- .../modtool/ModToolPickTicketEvent.java | 11 ++- .../modtool/ModToolReleaseTicketEvent.java | 13 ++-- .../ModToolRequestIssueChatlogEvent.java | 8 ++- .../ModToolRequestRoomChatlogEvent.java | 8 ++- .../ModToolRequestRoomUserChatlogEvent.java | 4 ++ .../ModToolRequestUserChatlogEvent.java | 5 ++ .../incoming/modtool/ModToolTicketGuard.java | 28 ++++++++ .../modtool/ModToolTicketGuardTest.java | 22 ++++++ .../ModToolTicketLifecycleContractTest.java | 67 +++++++++++++++++++ 11 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java index 98320092..591b1de9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolCloseTicketEvent.java @@ -15,9 +15,13 @@ public class ModToolCloseTicketEvent extends MessageHandler { this.packet.readInt(); int ticketId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(ticketId) || state < 1 || state > 3) { + return; + } + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); - if (issue == null || issue.modId != this.client.getHabbo().getHabboInfo().getId()) + if (!ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())) return; Habbo sender = Emulator.getGameEnvironment().getHabboManager().getHabbo(issue.senderId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java index 64655536..e2c69701 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java @@ -14,9 +14,17 @@ public class ModToolIssueChangeTopicEvent extends MessageHandler { this.packet.readInt(); int categoryId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(ticketId) || !ModToolTicketGuard.isPositiveId(categoryId)) { + return; + } + + if (Emulator.getGameEnvironment().getModToolManager().getCfhTopic(categoryId) == null) { + return; + } + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); - if (issue != null) { + if (ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())) { issue.category = categoryId; new UpdateModToolIssue(issue).run(); Emulator.getGameEnvironment().getModToolManager().updateTicketToMods(issue); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java index 172ad656..2bbd68ce 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolPickTicketEvent.java @@ -15,10 +15,17 @@ public class ModToolPickTicketEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { this.packet.readInt(); - ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(this.packet.readInt()); + int ticketId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(ticketId)) { + this.client.getHabbo().alert(Emulator.getTexts().getValue("support.ticket.picked.failed")); + return; + } + + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); if (issue != null) { - if (issue.state == ModToolTicketState.PICKED) { + if (!ModToolTicketGuard.canPick(issue)) { this.client.sendResponse(new ModToolIssueInfoComposer(issue)); this.client.getHabbo().alert(Emulator.getTexts().getValue("support.ticket.picked.failed")); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java index e0a155d9..cc08c125 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolReleaseTicketEvent.java @@ -13,17 +13,22 @@ public class ModToolReleaseTicketEvent extends MessageHandler { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int count = this.packet.readInt(); + if (!ModToolTicketGuard.isValidReleaseBatch(count)) { + return; + } + while (count != 0) { count--; int ticketId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(ticketId)) { + continue; + } + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); - if (issue == null) - continue; - - if (issue.modId != this.client.getHabbo().getHabboInfo().getId()) + if (!ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())) continue; issue.modId = 0; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java index 4eb2756e..0bc5df4d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestIssueChatlogEvent.java @@ -16,7 +16,13 @@ public class ModToolRequestIssueChatlogEvent extends MessageHandler { @Override public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { - ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(this.packet.readInt()); + int ticketId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(ticketId)) { + return; + } + + ModToolIssue issue = Emulator.getGameEnvironment().getModToolManager().getTicket(ticketId); if (issue != null) { List chatlog = new ArrayList<>(); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java index 5f58f50e..2e2e3de1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomChatlogEvent.java @@ -12,7 +12,13 @@ public class ModToolRequestRoomChatlogEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { this.packet.readInt(); - Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(this.packet.readInt()); + int roomId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(roomId)) { + return; + } + + Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); if (room != null) this.client.sendResponse(new ModToolRoomChatlogComposer(room, Emulator.getGameEnvironment().getModToolManager().getRoomChatlog(room.getId()))); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java index 6fec0a90..a674ab4c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestRoomUserChatlogEvent.java @@ -14,6 +14,10 @@ public class ModToolRequestRoomUserChatlogEvent extends MessageHandler { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int userId = this.packet.readInt(); + if (!ModToolTicketGuard.isPositiveId(userId)) { + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (habbo != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java index a175cdd0..f4ab8040 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java @@ -13,6 +13,11 @@ public class ModToolRequestUserChatlogEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int userId = this.packet.readInt(); + + if (!ModToolTicketGuard.isPositiveId(userId)) { + return; + } + HabboInfo habboInfo = HabboManager.getOfflineHabboInfo(userId); if (habboInfo == null) { return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java new file mode 100644 index 00000000..a5844c6d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuard.java @@ -0,0 +1,28 @@ +package com.eu.habbo.messages.incoming.modtool; + +import com.eu.habbo.habbohotel.modtool.ModToolIssue; +import com.eu.habbo.habbohotel.modtool.ModToolTicketState; +import com.eu.habbo.habbohotel.users.Habbo; + +final class ModToolTicketGuard { + static final int MAX_RELEASE_BATCH = 50; + + private ModToolTicketGuard() { + } + + static boolean isPositiveId(int id) { + return id > 0; + } + + static boolean isValidReleaseBatch(int count) { + return count > 0 && count <= MAX_RELEASE_BATCH; + } + + static boolean isOwnedBy(ModToolIssue issue, Habbo moderator) { + return issue != null && moderator != null && issue.modId == moderator.getHabboInfo().getId(); + } + + static boolean canPick(ModToolIssue issue) { + return issue != null && issue.state != ModToolTicketState.PICKED; + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java new file mode 100644 index 00000000..a6255d16 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketGuardTest.java @@ -0,0 +1,22 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModToolTicketGuardTest { + @Test + void idsMustBePositive() { + assertFalse(ModToolTicketGuard.isPositiveId(0)); + assertFalse(ModToolTicketGuard.isPositiveId(-1)); + assertTrue(ModToolTicketGuard.isPositiveId(1)); + } + + @Test + void releaseBatchIsBounded() { + assertFalse(ModToolTicketGuard.isValidReleaseBatch(0)); + assertTrue(ModToolTicketGuard.isValidReleaseBatch(ModToolTicketGuard.MAX_RELEASE_BATCH)); + assertFalse(ModToolTicketGuard.isValidReleaseBatch(ModToolTicketGuard.MAX_RELEASE_BATCH + 1)); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java new file mode 100644 index 00000000..b74a7aee --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/modtool/ModToolTicketLifecycleContractTest.java @@ -0,0 +1,67 @@ +package com.eu.habbo.messages.incoming.modtool; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModToolTicketLifecycleContractTest { + @Test + void mutatingTicketActionsValidateOwnership() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ModToolCloseTicketEvent.java", + "ModToolIssueChangeTopicEvent.java", + "ModToolReleaseTicketEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolTicketGuard.isOwnedBy(issue, this.client.getHabbo())"), + handler + " must only mutate tickets owned by the acting moderator"); + } + } + + @Test + void clientDrivenTicketAndChatlogIdsAreValidated() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + + for (String handler : List.of( + "ModToolPickTicketEvent.java", + "ModToolCloseTicketEvent.java", + "ModToolIssueChangeTopicEvent.java", + "ModToolRequestIssueChatlogEvent.java", + "ModToolRequestRoomChatlogEvent.java", + "ModToolRequestRoomUserChatlogEvent.java", + "ModToolRequestUserChatlogEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("ModToolTicketGuard.isPositiveId"), + handler + " must reject zero or negative client-provided ids"); + } + } + + @Test + void releaseBatchAndCloseStateAreBounded() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool"); + String release = Files.readString(base.resolve("ModToolReleaseTicketEvent.java")); + String close = Files.readString(base.resolve("ModToolCloseTicketEvent.java")); + + assertTrue(release.contains("ModToolTicketGuard.isValidReleaseBatch(count)"), + "release ticket batches must be bounded before reading ticket ids"); + assertTrue(close.contains("state < 1 || state > 3"), + "close ticket must reject unknown close states before mutating the ticket"); + } + + @Test + void changeTopicRequiresKnownCategory() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolIssueChangeTopicEvent.java")); + + assertTrue(source.contains("getCfhTopic(categoryId) == null"), + "change-topic must reject unknown CFH categories before persisting"); + } +} From dac83e8a626fd5ebd3147ae904167fa69037ef8f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 15 Jun 2026 20:25:48 +0200 Subject: [PATCH 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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