uniqueParts = new HashSet<>();
+
+ for (String part : parts) {
+ if (!ALLOWED_PARTS.contains(part) || !uniqueParts.add(part)) {
+ return DEFAULT_DISPLAY_ORDER;
+ }
+ }
+
+ return String.join("-", parts);
+ }
+
+ public void dispose() {
+ this.displayOrder = DEFAULT_DISPLAY_ORDER;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/api/IWiredEffect.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/api/IWiredEffect.java
index ed972341..4e7cefb2 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/api/IWiredEffect.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/api/IWiredEffect.java
@@ -77,6 +77,22 @@ public interface IWiredEffect {
return false;
}
+ /**
+ * Selectors can use this to gate stack execution after their target list has
+ * been resolved. Returning false stops the stack before conditions/effects.
+ */
+ default boolean hasRequiredSelectorTargets(WiredContext ctx) {
+ return true;
+ }
+
+ /**
+ * Selectors that filter the current selector result should run after
+ * selectors that create/replace that result.
+ */
+ default boolean usesExistingSelectorTargets() {
+ return false;
+ }
+
/**
* Simulate this effect's execution and record intended state changes.
*
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredContext.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredContext.java
index 0faeee03..e88f2bf0 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredContext.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredContext.java
@@ -112,7 +112,7 @@ public final class WiredContext {
this.state = state;
this.legacySettings = legacySettings;
this.contextVariables = (event.getContextVariableScope() != null)
- ? event.getContextVariableScope()
+ ? event.getContextVariableScope().copy()
: new WiredContextVariableScope();
this.targets = new WiredTargets();
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredEngine.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredEngine.java
index 91f25afe..4301b2b8 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredEngine.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredEngine.java
@@ -26,6 +26,7 @@ import com.eu.habbo.habbohotel.wired.api.WiredStack;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
import com.eu.habbo.messages.outgoing.generic.alerts.GenericAlertComposer;
+import com.eu.habbo.messages.outgoing.rooms.items.ItemStateComposer;
import com.eu.habbo.plugin.events.furniture.wired.WiredStackExecutedEvent;
import com.eu.habbo.plugin.events.furniture.wired.WiredStackTriggeredEvent;
import gnu.trove.map.hash.THashMap;
@@ -130,6 +131,9 @@ public final class WiredEngine {
/** Cache room+eventType+sourceItemId -> matching stacks for source-triggered timer events */
private final ConcurrentHashMap> sourceStacksByTriggerKey;
+ /** Track filter-selector animation tokens so rapid executions do not reset newer animations */
+ private final ConcurrentHashMap filteredSelectorAnimationTokens;
+
/**
* Create a new wired engine.
*
@@ -151,6 +155,7 @@ public final class WiredEngine {
this.bannedRooms = new ConcurrentHashMap<>();
this.roomDiagnostics = new ConcurrentHashMap<>();
this.sourceStacksByTriggerKey = new ConcurrentHashMap<>();
+ this.filteredSelectorAnimationTokens = new ConcurrentHashMap<>();
}
/**
@@ -426,6 +431,10 @@ public final class WiredEngine {
applySelectionFilterExtras(stack, ctx, executedSelectors);
}
+ if (!selectorsHaveRequiredTargets(executedSelectors, ctx)) {
+ return false;
+ }
+
boolean conditionsPassedForExecution = getConditionOutcomeForExecution(stack, ctx, negateConditions);
List executableEffects = getExecutableEffectsForCurrentExecution(stack, conditionsPassedForExecution);
boolean hasSpecialOutcome = conditionsPassedForExecution && hasSpecialTriggerOutcome(stack, event);
@@ -541,6 +550,10 @@ public final class WiredEngine {
applySelectionFilterExtras(stack, ctx, executedSelectors);
}
+ if (!selectorsHaveRequiredTargets(executedSelectors, ctx)) {
+ return false;
+ }
+
boolean conditionsPassedForExecution = getConditionOutcomeForExecution(stack, ctx, negateConditions);
List executableEffects = getExecutableEffectsForCurrentExecution(stack, conditionsPassedForExecution);
boolean hasSpecialOutcome = conditionsPassedForExecution && hasSpecialTriggerOutcome(stack, event);
@@ -627,6 +640,10 @@ public final class WiredEngine {
applySelectionFilterExtras(stack, ctx, executedSelectors);
}
+ if (!selectorsHaveRequiredTargets(executedSelectors, ctx)) {
+ return false;
+ }
+
boolean conditionsPassedForExecution = getConditionOutcomeForExecution(stack, ctx, negateConditions);
List executableEffects = getExecutableEffectsForCurrentExecution(stack, conditionsPassedForExecution);
return !executableEffects.isEmpty();
@@ -660,6 +677,10 @@ public final class WiredEngine {
applySelectionFilterExtras(stack, ctx, executedSelectors);
}
+ if (!selectorsHaveRequiredTargets(executedSelectors, ctx)) {
+ return false;
+ }
+
boolean conditionsPassedForExecution = getConditionOutcomeForExecution(stack, ctx, false);
if (!conditionsPassedForExecution) {
return false;
@@ -1011,9 +1032,27 @@ public final class WiredEngine {
if (effects.isEmpty()) return Collections.emptyList();
List executedSelectors = new ArrayList<>();
+ List immediateSelectors = new ArrayList<>();
+ List deferredSelectors = new ArrayList<>();
for (IWiredEffect effect : effects) {
if (!effect.isSelector()) continue;
+
+ if (effect.usesExistingSelectorTargets()) {
+ deferredSelectors.add(effect);
+ } else {
+ immediateSelectors.add(effect);
+ }
+ }
+
+ executeSelectorList(immediateSelectors, ctx, executedSelectors);
+ executeSelectorList(deferredSelectors, ctx, executedSelectors);
+
+ return executedSelectors;
+ }
+
+ private void executeSelectorList(List selectors, WiredContext ctx, List executedSelectors) {
+ for (IWiredEffect effect : selectors) {
if (effect.requiresActor() && !ctx.hasActor()) {
continue;
}
@@ -1022,14 +1061,17 @@ public final class WiredEngine {
try {
effect.execute(ctx);
if (effect instanceof InteractionWiredEffect) {
- executedSelectors.add((InteractionWiredEffect) effect);
+ InteractionWiredEffect wiredEffect = (InteractionWiredEffect) effect;
+ executedSelectors.add(wiredEffect);
+
+ if (wiredEffect.usesExistingSelectorTargets()) {
+ setFilteredSelectorState(ctx.room(), wiredEffect, "3");
+ }
}
} catch (Exception e) {
LOGGER.warn("Error executing selector: {}", e.getMessage());
}
}
-
- return executedSelectors;
}
private void finalizeSelectors(List executedSelectors, WiredContext ctx, long currentTime) {
@@ -1042,7 +1084,56 @@ public final class WiredEngine {
for (InteractionWiredEffect wiredEffect : executedSelectors) {
wiredEffect.setCooldown(currentTime);
- wiredEffect.activateBox(room, actor, currentTime);
+
+ if (wiredEffect.usesExistingSelectorTargets()) {
+ animateFilteredSelectorBox(room, wiredEffect);
+ } else {
+ wiredEffect.activateBox(room, actor, currentTime);
+ }
+ }
+ }
+
+ private void animateFilteredSelectorBox(Room room, InteractionWiredEffect wiredEffect) {
+ if (room == null || wiredEffect == null || room.isHideWired()) {
+ return;
+ }
+
+ long animationToken = System.nanoTime();
+ this.filteredSelectorAnimationTokens.put(wiredEffect.getId(), animationToken);
+
+ setFilteredSelectorState(room, wiredEffect, "3", animationToken, false);
+ scheduleFilteredSelectorState(room, wiredEffect, "4", animationToken, 80L, false);
+ scheduleFilteredSelectorState(room, wiredEffect, "5", animationToken, 160L, false);
+ scheduleFilteredSelectorState(room, wiredEffect, "3", animationToken, 240L, true);
+ }
+
+ private void scheduleFilteredSelectorState(Room room, InteractionWiredEffect wiredEffect, String state, long animationToken, long delay, boolean clearToken) {
+ Emulator.getThreading().run(() -> setFilteredSelectorState(room, wiredEffect, state, animationToken, clearToken), delay);
+ }
+
+ private void setFilteredSelectorState(Room room, InteractionWiredEffect wiredEffect, String state) {
+ setFilteredSelectorState(room, wiredEffect, state, 0L, false);
+ }
+
+ private void setFilteredSelectorState(Room room, InteractionWiredEffect wiredEffect, String state, long animationToken, boolean clearToken) {
+ if (room == null || wiredEffect == null || room.isHideWired()) {
+ return;
+ }
+
+ if (animationToken != 0L) {
+ Long currentToken = this.filteredSelectorAnimationTokens.get(wiredEffect.getId());
+ if (currentToken == null || currentToken != animationToken) {
+ return;
+ }
+ }
+
+ if (!state.equals(wiredEffect.getExtradata())) {
+ wiredEffect.setExtradata(state);
+ room.sendComposer(new ItemStateComposer(wiredEffect).compose());
+ }
+
+ if (clearToken) {
+ this.filteredSelectorAnimationTokens.remove(wiredEffect.getId(), animationToken);
}
}
@@ -1059,6 +1150,20 @@ public final class WiredEngine {
WiredSelectionFilterSupport.applySelectorFilters(room, stack.triggerItem(), ctx);
}
+ private boolean selectorsHaveRequiredTargets(List executedSelectors, WiredContext ctx) {
+ if (executedSelectors == null || executedSelectors.isEmpty()) {
+ return true;
+ }
+
+ for (InteractionWiredEffect selector : executedSelectors) {
+ if (!selector.hasRequiredSelectorTargets(ctx)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* Schedule a delayed effect execution.
*/
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredSourceUtil.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredSourceUtil.java
index 00e41a15..201461e5 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredSourceUtil.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredSourceUtil.java
@@ -151,8 +151,20 @@ public final class WiredSourceUtil {
selectorCtx.setIncludeWiredSelectorItems(originalCtx.includeWiredSelectorItems());
List selectorEffects = getOrderedSelectorEffects(originalCtx, room, triggerItem);
+ executeSelectorEffects(selectorCtx, selectorEffects, false);
+ executeSelectorEffects(selectorCtx, selectorEffects, true);
+ applySelectionFilterExtras(room, triggerItem, selectorCtx);
+
+ return selectorCtx;
+ }
+
+ private static void executeSelectorEffects(WiredContext selectorCtx, List selectorEffects, boolean deferred) {
for (InteractionWiredEffect effect : selectorEffects) {
+ if (effect == null || effect.usesExistingSelectorTargets() != deferred) {
+ continue;
+ }
+
if (effect.requiresActor() && !selectorCtx.hasActor()) {
continue;
}
@@ -163,10 +175,6 @@ public final class WiredSourceUtil {
} catch (Exception ignored) {
}
}
-
- applySelectionFilterExtras(room, triggerItem, selectorCtx);
-
- return selectorCtx;
}
private static WiredContext cloneSelectorContext(WiredContext originalCtx, boolean includeWiredItems) {
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextInputCaptureSupport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextInputCaptureSupport.java
index cb73d427..1a6bc9d6 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextInputCaptureSupport.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextInputCaptureSupport.java
@@ -64,8 +64,17 @@ public final class WiredTextInputCaptureSupport {
return trigger.matches(stack.triggerItem(), event) ? CaptureResult.matched(new LinkedHashMap<>()) : CaptureResult.noMatch();
}
- MatchResult matchResult = matchTemplate(trigger, text, capturersByName);
+ MatchResult matchResult = matchTemplate(trigger, text, capturersByName, room);
if (!matchResult.matches) {
+ if (WiredManager.isDebugEnabled()) {
+ WiredManager.debug("[TextCapture] NO_MATCH room={} triggerId={} mode={} key='{}' text='{}' len={}",
+ room.getId(),
+ stack.triggerItem().getId(),
+ trigger.getMatchMode(),
+ safeForLog(trigger.getKey()),
+ safeForLog(text),
+ (text != null ? text.length() : 0));
+ }
return CaptureResult.noMatch();
}
@@ -78,12 +87,28 @@ public final class WiredTextInputCaptureSupport {
Integer resolvedValue = capturer.resolveCapturedValue(room, capture.getValue());
if (resolvedValue == null) {
+ if (WiredManager.isDebugEnabled()) {
+ WiredManager.debug("[TextCapture] RESOLVE_FAIL room={} triggerId={} capturer='{}' raw='{}' rawLen={}",
+ room.getId(),
+ stack.triggerItem().getId(),
+ capture.getKey(),
+ safeForLog(capture.getValue()),
+ (capture.getValue() != null ? capture.getValue().length() : 0));
+ }
return CaptureResult.noMatch();
}
capturedValues.put(capturer.getVariableItemId(), resolvedValue);
}
+ if (WiredManager.isDebugEnabled()) {
+ WiredManager.debug("[TextCapture] MATCH_OK room={} triggerId={} captures={} textLen={}",
+ room.getId(),
+ stack.triggerItem().getId(),
+ capturedValues.size(),
+ (text != null ? text.length() : 0));
+ }
+
return CaptureResult.matched(capturedValues);
}
@@ -108,12 +133,13 @@ public final class WiredTextInputCaptureSupport {
return capturers;
}
- private static MatchResult matchTemplate(WiredTriggerHabboSaysKeyword trigger, String rawText, Map capturersByName) {
- String text = rawText != null ? rawText.trim() : "";
+ private static MatchResult matchTemplate(WiredTriggerHabboSaysKeyword trigger, String rawText, Map capturersByName, Room room) {
+ String text = rawText != null ? rawText : "";
+ String normalizedText = text.trim();
String template = trigger.getKey() != null ? trigger.getKey().trim() : "";
if (trigger.getMatchMode() == MATCH_ALL_WORDS && template.isEmpty()) {
- if (capturersByName.size() != 1 || text.isEmpty()) {
+ if (capturersByName.size() != 1 || normalizedText.isEmpty()) {
return MatchResult.noMatch();
}
@@ -123,12 +149,24 @@ public final class WiredTextInputCaptureSupport {
return MatchResult.matched(captures);
}
+ MatchResult adjacentCaptureResult = matchAdjacentCapturers(template, rawText, capturersByName, room, trigger.getMatchMode());
+ if (adjacentCaptureResult != null) {
+ if (WiredManager.isDebugEnabled()) {
+ WiredManager.debug("[TextCapture] ADJACENT mode used key='{}' textLen={} matched={}",
+ safeForLog(template),
+ (rawText != null ? rawText.length() : 0),
+ adjacentCaptureResult.matches);
+ }
+ return adjacentCaptureResult;
+ }
+
TemplatePattern pattern = buildPattern(template);
if (pattern == null) {
return MatchResult.noMatch();
}
- Matcher matcher = pattern.pattern.matcher(text);
+ String matchText = pattern.placeholderNames.isEmpty() ? normalizedText : text;
+ Matcher matcher = pattern.pattern.matcher(matchText);
boolean matches = (trigger.getMatchMode() == MATCH_CONTAINS) ? matcher.find() : matcher.matches();
if (!matches) {
return MatchResult.noMatch();
@@ -142,12 +180,136 @@ public final class WiredTextInputCaptureSupport {
}
String capturedValue = matcher.group(index + 1);
- captures.put(placeholderName, capturedValue != null ? capturedValue.trim() : "");
+ captures.put(placeholderName, normalizeCapturedValue(capturedValue));
}
return MatchResult.matched(captures);
}
+ private static MatchResult matchAdjacentCapturers(String template, String rawText, Map capturersByName, Room room, int matchMode) {
+ if (template == null || template.isEmpty() || rawText == null || capturersByName == null || capturersByName.isEmpty() || room == null) {
+ return null;
+ }
+
+ Matcher matcher = PLACEHOLDER_PATTERN.matcher(template);
+ List placeholderNames = new ArrayList<>();
+ int cursor = 0;
+
+ while (matcher.find()) {
+ if (matcher.start() != cursor) {
+ return null;
+ }
+
+ String placeholderName = matcher.group(1) != null ? matcher.group(1).trim().toLowerCase() : "";
+ if (placeholderName.isEmpty() || !capturersByName.containsKey(placeholderName)) {
+ return null;
+ }
+
+ placeholderNames.add(placeholderName);
+ cursor = matcher.end();
+ }
+
+ if (placeholderNames.isEmpty() || cursor != template.length()) {
+ return null;
+ }
+
+ int placeholderCount = placeholderNames.size();
+ int textLength = rawText.length();
+
+ boolean[][] reachable = new boolean[placeholderCount + 1][textLength + 1];
+ int[][] previousIndex = new int[placeholderCount + 1][textLength + 1];
+ String[][] capturedValues = new String[placeholderCount + 1][textLength + 1];
+
+ for (int placeholderIndex = 0; placeholderIndex <= placeholderCount; placeholderIndex++) {
+ for (int textIndex = 0; textIndex <= textLength; textIndex++) {
+ previousIndex[placeholderIndex][textIndex] = -1;
+ }
+ }
+
+ reachable[0][0] = true;
+
+ for (int placeholderIndex = 0; placeholderIndex < placeholderCount; placeholderIndex++) {
+ String placeholderName = placeholderNames.get(placeholderIndex);
+ WiredExtraTextInputVariable capturer = capturersByName.get(placeholderName);
+ if (capturer == null) {
+ return MatchResult.noMatch();
+ }
+
+ for (int textIndex = 0; textIndex <= textLength; textIndex++) {
+ if (!reachable[placeholderIndex][textIndex]) {
+ continue;
+ }
+
+ int minEndIndex = (textIndex < textLength) ? (textIndex + 1) : textIndex;
+ for (int endIndex = minEndIndex; endIndex <= textLength; endIndex++) {
+ if (reachable[placeholderIndex + 1][endIndex]) {
+ continue;
+ }
+
+ String candidate = rawText.substring(textIndex, endIndex);
+ if (capturer.resolveCapturedValue(room, candidate) == null) {
+ continue;
+ }
+
+ reachable[placeholderIndex + 1][endIndex] = true;
+ previousIndex[placeholderIndex + 1][endIndex] = textIndex;
+ capturedValues[placeholderIndex + 1][endIndex] = candidate;
+ }
+ }
+ }
+
+ int resultEndIndex = -1;
+ if (matchMode == MATCH_CONTAINS) {
+ for (int endIndex = textLength; endIndex >= 0; endIndex--) {
+ if (reachable[placeholderCount][endIndex]) {
+ resultEndIndex = endIndex;
+ break;
+ }
+ }
+ } else if (reachable[placeholderCount][textLength]) {
+ resultEndIndex = textLength;
+ }
+
+ if (resultEndIndex < 0) {
+ return MatchResult.noMatch();
+ }
+
+ LinkedHashMap captures = new LinkedHashMap<>();
+ int backtrackTextIndex = resultEndIndex;
+ for (int placeholderIndex = placeholderCount; placeholderIndex > 0; placeholderIndex--) {
+ String placeholderName = placeholderNames.get(placeholderIndex - 1);
+ String capturedValue = capturedValues[placeholderIndex][backtrackTextIndex];
+ captures.put(placeholderName, capturedValue != null ? capturedValue : "");
+ backtrackTextIndex = previousIndex[placeholderIndex][backtrackTextIndex];
+ if (backtrackTextIndex < 0) {
+ return MatchResult.noMatch();
+ }
+ }
+
+ return MatchResult.matched(captures);
+ }
+
+ private static String normalizeCapturedValue(String value) {
+ return value != null ? value : "";
+ }
+
+ private static String safeForLog(String value) {
+ if (value == null) {
+ return "";
+ }
+
+ String normalized = value
+ .replace("\r", "\\r")
+ .replace("\n", "\\n")
+ .replace("\u00A0", "⍽");
+
+ if (normalized.length() > 180) {
+ return normalized.substring(0, 180) + "...(" + normalized.length() + ")";
+ }
+
+ return normalized;
+ }
+
private static TemplatePattern buildPattern(String template) {
if (template == null || template.isEmpty()) {
return null;
@@ -160,7 +322,7 @@ public final class WiredTextInputCaptureSupport {
while (matcher.find()) {
regex.append(Pattern.quote(template.substring(cursor, matcher.start())));
- regex.append("(.+?)");
+ regex.append(hasPlaceholderAfter(template, matcher.end()) ? "(.+?)" : "(.+)");
String placeholderName = matcher.group(1) != null ? matcher.group(1).trim().toLowerCase() : "";
placeholderNames.add(placeholderName);
@@ -176,6 +338,10 @@ public final class WiredTextInputCaptureSupport {
return new TemplatePattern(Pattern.compile(regex.toString(), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE), placeholderNames);
}
+ private static boolean hasPlaceholderAfter(String template, int cursor) {
+ return PLACEHOLDER_PATTERN.matcher(template.substring(cursor)).find();
+ }
+
public static void applyToContext(WiredContext ctx, Room room, CaptureResult captureResult) {
if (ctx == null || room == null || captureResult == null || !captureResult.matches || captureResult.capturedValues.isEmpty()) {
return;
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java
index d1db42ab..4f3631b8 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java
@@ -32,6 +32,8 @@ import java.util.List;
import java.util.Locale;
public final class WiredTextPlaceholderUtil {
+ private static final char PRESERVED_SPACE = '\u00A0';
+
private WiredTextPlaceholderUtil() {
}
@@ -87,7 +89,41 @@ public final class WiredTextPlaceholderUtil {
}
}
- return resolvedText;
+ return preserveRepeatedSpaces(resolvedText);
+ }
+
+ private static String preserveRepeatedSpaces(String text) {
+ if (text == null || text.length() < 2) {
+ return text;
+ }
+
+ StringBuilder result = new StringBuilder(text.length());
+ int index = 0;
+ while (index < text.length()) {
+ char currentChar = text.charAt(index);
+ if (currentChar != ' ') {
+ result.append(currentChar);
+ index++;
+ continue;
+ }
+
+ int startIndex = index;
+ while (index < text.length() && text.charAt(index) == ' ') {
+ index++;
+ }
+
+ int spaceCount = index - startIndex;
+ if (spaceCount == 1) {
+ result.append(' ');
+ continue;
+ }
+
+ for (int spaceIndex = 0; spaceIndex < spaceCount; spaceIndex++) {
+ result.append(PRESERVED_SPACE);
+ }
+ }
+
+ return result.toString();
}
public static boolean requiresActor(Room room, HabboItem stackItem) {
@@ -275,7 +311,7 @@ public final class WiredTextPlaceholderUtil {
}
String value = resolveRoomVariableValue(room, extra);
- return (value == null || value.isEmpty()) ? List.of() : List.of(value);
+ return value == null ? List.of() : List.of(value);
}
private static List collectContextVariableValues(WiredContext ctx, WiredExtraTextOutputVariable extra) {
@@ -284,7 +320,7 @@ public final class WiredTextPlaceholderUtil {
}
String value = resolveContextVariableValue(ctx, extra);
- return (value == null || value.isEmpty()) ? List.of() : List.of(value);
+ return value == null ? List.of() : List.of(value);
}
private static String resolveUserVariableValue(Room room, RoomUnit roomUnit, WiredExtraTextOutputVariable extra) {
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredVariableTextConnectorSupport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredVariableTextConnectorSupport.java
index 38764d79..3b076e0b 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredVariableTextConnectorSupport.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredVariableTextConnectorSupport.java
@@ -11,6 +11,8 @@ import java.util.List;
import java.util.Map;
public final class WiredVariableTextConnectorSupport {
+ private static final String PRESERVED_SPACE = "\u00A0";
+
private WiredVariableTextConnectorSupport() {
}
@@ -71,7 +73,7 @@ public final class WiredVariableTextConnectorSupport {
Map mappings = connector.getMappings();
if (mappings.containsKey(value)) {
String mappedValue = mappings.get(value);
- return mappedValue != null ? mappedValue : String.valueOf(value);
+ return mappedValue != null ? preserveSpaces(mappedValue) : "";
}
}
@@ -83,10 +85,7 @@ public final class WiredVariableTextConnectorSupport {
return null;
}
- String normalizedText = text.trim();
- if (normalizedText.isEmpty()) {
- return null;
- }
+ String normalizedText = normalizePreservedSpaces(text);
for (WiredExtraVariableTextConnector connector : getConnectors(room, definitionItemId)) {
Integer mappedValue = connector.resolveValue(normalizedText);
@@ -97,4 +96,12 @@ public final class WiredVariableTextConnectorSupport {
return null;
}
+
+ private static String preserveSpaces(String value) {
+ return value.replace(" ", PRESERVED_SPACE);
+ }
+
+ private static String normalizePreservedSpaces(String value) {
+ return value.replace(PRESERVED_SPACE, " ");
+ }
}
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 e1e4d312..0d38b908 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
@@ -36,6 +36,7 @@ import com.eu.habbo.messages.incoming.helper.MySanctionStatusEvent;
import com.eu.habbo.messages.incoming.helper.RequestTalentTrackEvent;
import com.eu.habbo.messages.incoming.hotelview.*;
import com.eu.habbo.messages.incoming.inventory.*;
+import com.eu.habbo.messages.incoming.inventory.nickicons.*;
import com.eu.habbo.messages.incoming.inventory.prefixes.*;
import com.eu.habbo.messages.incoming.modtool.*;
import com.eu.habbo.messages.incoming.navigator.*;
@@ -61,6 +62,8 @@ import com.eu.habbo.messages.incoming.rooms.promotions.RequestPromotionRoomsEven
import com.eu.habbo.messages.incoming.rooms.promotions.UpdateRoomPromotionEvent;
import com.eu.habbo.messages.incoming.rooms.users.*;
import com.eu.habbo.messages.incoming.trading.*;
+import com.eu.habbo.messages.incoming.translation.TranslationLanguagesRequestEvent;
+import com.eu.habbo.messages.incoming.translation.TranslationTextRequestEvent;
import com.eu.habbo.messages.incoming.unknown.RequestResolutionEvent;
import com.eu.habbo.messages.incoming.unknown.UnknownEvent1;
import com.eu.habbo.messages.incoming.users.*;
@@ -117,6 +120,7 @@ public class PacketManager {
this.registerGuilds();
this.registerPets();
this.registerWired();
+ this.registerTranslation();
this.registerAchievements();
this.registerFloorPlanEditor();
this.registerAmbassadors();
@@ -409,6 +413,13 @@ public class PacketManager {
this.registerHandler(Incoming.SetActivePrefixEvent, SetActivePrefixEvent.class);
this.registerHandler(Incoming.DeletePrefixEvent, DeletePrefixEvent.class);
this.registerHandler(Incoming.PurchasePrefixEvent, PurchasePrefixEvent.class);
+ this.registerHandler(Incoming.PurchaseCatalogPrefixEvent, PurchaseCatalogPrefixEvent.class);
+ this.registerHandler(Incoming.SetDisplayOrderEvent, SetDisplayOrderEvent.class);
+
+ // Nick Icons
+ this.registerHandler(Incoming.RequestUserNickIconsEvent, RequestUserNickIconsEvent.class);
+ this.registerHandler(Incoming.PurchaseNickIconEvent, PurchaseNickIconEvent.class);
+ this.registerHandler(Incoming.SetActiveNickIconEvent, SetActiveNickIconEvent.class);
}
void registerRooms() throws Exception {
@@ -635,6 +646,11 @@ public class PacketManager {
this.registerHandler(Incoming.WiredUserInspectMoveEvent, WiredUserInspectMoveEvent.class);
}
+ void registerTranslation() throws Exception {
+ this.registerHandler(Incoming.TranslationLanguagesRequestEvent, TranslationLanguagesRequestEvent.class);
+ this.registerHandler(Incoming.TranslationTextRequestEvent, TranslationTextRequestEvent.class);
+ }
+
void registerUnknown() throws Exception {
this.registerHandler(Incoming.RequestResolutionEvent, RequestResolutionEvent.class);
this.registerHandler(Incoming.RequestTalenTrackEvent, RequestTalentTrackEvent.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 d2b24daf..77ef0064 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
@@ -419,6 +419,8 @@ public class Incoming {
public static final int WiredUserVariableUpdateEvent = 10025;
public static final int WiredUserVariableManageEvent = 10026;
public static final int WiredUserInspectMoveEvent = 10027;
+ public static final int TranslationLanguagesRequestEvent = 10032;
+ public static final int TranslationTextRequestEvent = 10033;
public static final int RequestInventoryPetDelete = 10030;
public static final int RequestInventoryBadgeDelete = 10031;
@@ -448,6 +450,11 @@ public class Incoming {
public static final int SetActivePrefixEvent = 7012;
public static final int DeletePrefixEvent = 7013;
public static final int PurchasePrefixEvent = 7014;
+ public static final int RequestUserNickIconsEvent = 7015;
+ public static final int PurchaseNickIconEvent = 7016;
+ public static final int SetActiveNickIconEvent = 7017;
+ public static final int PurchaseCatalogPrefixEvent = 7018;
+ public static final int SetDisplayOrderEvent = 7019;
// YouTube Room Broadcast
public static final int YouTubeRoomPlayEvent = 8001;
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/PurchaseNickIconEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/PurchaseNickIconEvent.java
new file mode 100644
index 00000000..4aeaa3ca
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/PurchaseNickIconEvent.java
@@ -0,0 +1,95 @@
+package com.eu.habbo.messages.incoming.inventory.nickicons;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.habbohotel.users.UserNickIcon;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
+import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
+import com.eu.habbo.messages.outgoing.users.UserCurrencyComposer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class PurchaseNickIconEvent extends MessageHandler {
+ private static final Logger LOGGER = LoggerFactory.getLogger(PurchaseNickIconEvent.class);
+
+ @Override
+ public int getRatelimit() {
+ return 500;
+ }
+
+ @Override
+ public void handle() throws Exception {
+ Habbo habbo = this.client.getHabbo();
+
+ if (habbo == null) {
+ return;
+ }
+
+ String requestedIconKey = normalizeIconKey(this.packet.readString());
+
+ if (requestedIconKey.isEmpty()) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Invalid nick icon selected."));
+ return;
+ }
+
+ if (habbo.getInventory().getNickIconsComponent().getNickIconByKey(requestedIconKey) != null) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "You already own this nick icon."));
+ return;
+ }
+
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement("SELECT points, points_type, enabled FROM custom_nick_icons_catalog WHERE icon_key = ? LIMIT 1")) {
+ statement.setString(1, requestedIconKey);
+
+ try (ResultSet set = statement.executeQuery()) {
+ if (!set.next() || !set.getBoolean("enabled")) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "This nick icon is not available."));
+ return;
+ }
+
+ int points = set.getInt("points");
+ int pointsType = set.getInt("points_type");
+
+ if (points > 0 && habbo.getHabboInfo().getCurrencyAmount(pointsType) < points) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough points."));
+ return;
+ }
+
+ if (points > 0) {
+ habbo.getHabboInfo().addCurrencyAmount(pointsType, -points);
+ this.client.sendResponse(new UserCurrencyComposer(habbo));
+ }
+
+ UserNickIcon nickIcon = new UserNickIcon(habbo.getHabboInfo().getId(), requestedIconKey);
+ nickIcon.run();
+ habbo.getInventory().getNickIconsComponent().addNickIcon(nickIcon);
+
+ this.client.sendResponse(new UserNickIconsComposer(habbo));
+ }
+ } catch (SQLException e) {
+ LOGGER.error("Caught SQL exception", e);
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Unable to purchase this nick icon right now."));
+ }
+ }
+
+ private String normalizeIconKey(String iconKey) {
+ if (iconKey == null) {
+ return "";
+ }
+
+ String normalized = iconKey.trim().toLowerCase();
+
+ if (normalized.endsWith(".gif")) {
+ normalized = normalized.substring(0, normalized.length() - 4);
+ }
+
+ return normalized.matches("^[a-z0-9_-]+$") ? normalized : "";
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/RequestUserNickIconsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/RequestUserNickIconsEvent.java
new file mode 100644
index 00000000..84d2344f
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/RequestUserNickIconsEvent.java
@@ -0,0 +1,11 @@
+package com.eu.habbo.messages.incoming.inventory.nickicons;
+
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
+
+public class RequestUserNickIconsEvent extends MessageHandler {
+ @Override
+ public void handle() throws Exception {
+ this.client.sendResponse(new UserNickIconsComposer(this.client.getHabbo()));
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/SetActiveNickIconEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/SetActiveNickIconEvent.java
new file mode 100644
index 00000000..74717f83
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/nickicons/SetActiveNickIconEvent.java
@@ -0,0 +1,34 @@
+package com.eu.habbo.messages.incoming.inventory.nickicons;
+
+import com.eu.habbo.habbohotel.users.UserNickIcon;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
+import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDataComposer;
+
+public class SetActiveNickIconEvent extends MessageHandler {
+ @Override
+ public void handle() throws Exception {
+ int nickIconId = this.packet.readInt();
+
+ if (nickIconId == 0) {
+ this.client.getHabbo().getInventory().getNickIconsComponent().deactivateAll();
+ this.client.sendResponse(new UserNickIconsComposer(this.client.getHabbo()));
+ if (this.client.getHabbo().getHabboInfo().getCurrentRoom() != null) {
+ this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(this.client.getHabbo()).compose());
+ }
+ return;
+ }
+
+ UserNickIcon nickIcon = this.client.getHabbo().getInventory().getNickIconsComponent().getNickIcon(nickIconId);
+
+ if (nickIcon == null) {
+ return;
+ }
+
+ this.client.getHabbo().getInventory().getNickIconsComponent().setActive(nickIconId);
+ this.client.sendResponse(new UserNickIconsComposer(this.client.getHabbo()));
+ if (this.client.getHabbo().getHabboInfo().getCurrentRoom() != null) {
+ this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(this.client.getHabbo()).compose());
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchaseCatalogPrefixEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchaseCatalogPrefixEvent.java
new file mode 100644
index 00000000..89c07a95
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchaseCatalogPrefixEvent.java
@@ -0,0 +1,84 @@
+package com.eu.habbo.messages.incoming.inventory.prefixes;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.habbohotel.users.UserPrefix;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
+import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
+import com.eu.habbo.messages.outgoing.users.UserCurrencyComposer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class PurchaseCatalogPrefixEvent extends MessageHandler {
+ private static final Logger LOGGER = LoggerFactory.getLogger(PurchaseCatalogPrefixEvent.class);
+
+ @Override
+ public int getRatelimit() {
+ return 500;
+ }
+
+ @Override
+ public void handle() throws Exception {
+ int catalogPrefixId = this.packet.readInt();
+ Habbo habbo = this.client.getHabbo();
+
+ if (habbo == null || catalogPrefixId <= 0) {
+ return;
+ }
+
+ if (habbo.getInventory().getPrefixesComponent().getPrefixByCatalogId(catalogPrefixId) != null) {
+ this.client.sendResponse(new UserNickIconsComposer(habbo));
+ return;
+ }
+
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT display_name, text, color, icon, effect, font, points, points_type FROM custom_prefixes_catalog WHERE id = ? AND enabled = 1 LIMIT 1")) {
+ statement.setInt(1, catalogPrefixId);
+
+ try (ResultSet set = statement.executeQuery()) {
+ if (!set.next()) {
+ return;
+ }
+
+ int points = set.getInt("points");
+ int pointsType = set.getInt("points_type");
+
+ if (points > 0 && habbo.getHabboInfo().getCurrencyAmount(pointsType) < points) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough points."));
+ return;
+ }
+
+ if (points > 0) {
+ habbo.getHabboInfo().addCurrencyAmount(pointsType, -points);
+ this.client.sendResponse(new UserCurrencyComposer(habbo));
+ }
+
+ UserPrefix prefix = new UserPrefix(
+ habbo.getHabboInfo().getId(),
+ set.getString("text"),
+ set.getString("color"),
+ set.getString("icon"),
+ set.getString("effect"),
+ set.getString("font"),
+ catalogPrefixId,
+ set.getString("display_name"),
+ points,
+ pointsType,
+ false);
+ prefix.run();
+ habbo.getInventory().getPrefixesComponent().addPrefix(prefix);
+ this.client.sendResponse(new UserNickIconsComposer(habbo));
+ }
+ } catch (SQLException e) {
+ LOGGER.error("Caught SQL exception while purchasing catalog prefix", e);
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchasePrefixEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchasePrefixEvent.java
index 8e04000f..54fa81c8 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchasePrefixEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/PurchasePrefixEvent.java
@@ -6,6 +6,7 @@ import com.eu.habbo.habbohotel.users.UserPrefix;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
import com.eu.habbo.messages.outgoing.inventory.prefixes.PrefixReceivedComposer;
import com.eu.habbo.messages.outgoing.users.UserCreditsComposer;
import com.eu.habbo.messages.outgoing.users.UserCurrencyComposer;
@@ -19,6 +20,7 @@ import java.sql.SQLException;
public class PurchasePrefixEvent extends MessageHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(PurchasePrefixEvent.class);
+ private static final String[] ALLOWED_FONTS = { "", "pixel", "cherry", "vampiro" };
@Override
public int getRatelimit() {
@@ -31,6 +33,7 @@ public class PurchasePrefixEvent extends MessageHandler {
String color = this.packet.readString();
String icon = this.packet.readString();
String effect = this.packet.readString();
+ String font = this.packet.readString();
Habbo habbo = this.client.getHabbo();
@@ -42,6 +45,9 @@ public class PurchasePrefixEvent extends MessageHandler {
int priceCredits = getSettingInt("price_credits", 5);
int pricePoints = getSettingInt("price_points", 0);
int pointsType = getSettingInt("points_type", 0);
+ int fontPriceCredits = getSettingInt("font_price_credits", 10);
+ int fontPricePoints = getSettingInt("font_price_points", 0);
+ int fontPointsType = getSettingInt("font_points_type", pointsType);
// Validate text
text = text.trim();
@@ -72,43 +78,67 @@ public class PurchasePrefixEvent extends MessageHandler {
return;
}
+ if (icon == null) icon = "";
+ icon = icon.trim();
+
+ if (effect == null) effect = "";
+ effect = effect.trim();
+
+ if (font == null) font = "";
+ font = font.trim().toLowerCase();
+
+ if (!isAllowedFont(font)) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Invalid font format."));
+ return;
+ }
+
+ int totalPriceCredits = priceCredits + (!font.isEmpty() ? fontPriceCredits : 0);
+
// Check credits
- if (priceCredits > 0 && habbo.getHabboInfo().getCredits() < priceCredits) {
+ if (totalPriceCredits > 0 && habbo.getHabboInfo().getCredits() < totalPriceCredits) {
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough credits."));
return;
}
+ int totalPricePointsSameType = pricePoints + ((fontPricePoints > 0 && fontPointsType == pointsType && !font.isEmpty()) ? fontPricePoints : 0);
+
// Check points
- if (pricePoints > 0 && habbo.getHabboInfo().getCurrencyAmount(pointsType) < pricePoints) {
+ if (totalPricePointsSameType > 0 && habbo.getHabboInfo().getCurrencyAmount(pointsType) < totalPricePointsSameType) {
+ this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough points."));
+ return;
+ }
+
+ if (!font.isEmpty() && fontPricePoints > 0 && fontPointsType != pointsType && habbo.getHabboInfo().getCurrencyAmount(fontPointsType) < fontPricePoints) {
this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FURNITURE_PLACEMENT_ERROR.key, "Not enough points."));
return;
}
// Deduct currency
- if (priceCredits > 0) {
- habbo.getHabboInfo().addCredits(-priceCredits);
+ if (totalPriceCredits > 0) {
+ habbo.getHabboInfo().addCredits(-totalPriceCredits);
this.client.sendResponse(new UserCreditsComposer(habbo));
}
- if (pricePoints > 0) {
- habbo.getHabboInfo().addCurrencyAmount(pointsType, -pricePoints);
+ if (totalPricePointsSameType > 0) {
+ habbo.getHabboInfo().addCurrencyAmount(pointsType, -totalPricePointsSameType);
this.client.sendResponse(new UserCurrencyComposer(habbo));
}
- // Validate icon (allow empty or known icon names)
- if (icon == null) icon = "";
- icon = icon.trim();
-
- // Validate effect
- if (effect == null) effect = "";
- effect = effect.trim();
+ if (!font.isEmpty() && fontPricePoints > 0 && fontPointsType != pointsType) {
+ habbo.getHabboInfo().addCurrencyAmount(fontPointsType, -fontPricePoints);
+ this.client.sendResponse(new UserCurrencyComposer(habbo));
+ }
// Create prefix
- UserPrefix prefix = new UserPrefix(habbo.getHabboInfo().getId(), text, color, icon, effect);
+ int storedPoints = totalPricePointsSameType;
+ int storedPointsType = (storedPoints > 0) ? pointsType : ((!font.isEmpty() && fontPricePoints > 0) ? fontPointsType : pointsType);
+
+ UserPrefix prefix = new UserPrefix(habbo.getHabboInfo().getId(), text, color, icon, effect, font, 0, text, storedPoints, storedPointsType, true);
prefix.run(); // Insert into DB synchronously to get the ID
habbo.getInventory().getPrefixesComponent().addPrefix(prefix);
this.client.sendResponse(new PrefixReceivedComposer(prefix));
+ this.client.sendResponse(new UserNickIconsComposer(habbo));
}
private int getSettingInt(String key, int defaultValue) {
@@ -142,4 +172,14 @@ public class PurchasePrefixEvent extends MessageHandler {
}
return false;
}
+
+ private boolean isAllowedFont(String font) {
+ for (String allowedFont : ALLOWED_FONTS) {
+ if (allowedFont.equals(font)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetActivePrefixEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetActivePrefixEvent.java
index 9ec5710a..16d88890 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetActivePrefixEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetActivePrefixEvent.java
@@ -3,6 +3,8 @@ package com.eu.habbo.messages.incoming.inventory.prefixes;
import com.eu.habbo.habbohotel.users.UserPrefix;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.inventory.prefixes.ActivePrefixUpdatedComposer;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
+import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDataComposer;
public class SetActivePrefixEvent extends MessageHandler {
@Override
@@ -12,6 +14,11 @@ public class SetActivePrefixEvent extends MessageHandler {
if (prefixId == 0) {
this.client.getHabbo().getInventory().getPrefixesComponent().deactivateAll();
this.client.sendResponse(new ActivePrefixUpdatedComposer(null));
+ this.client.sendResponse(new UserNickIconsComposer(this.client.getHabbo()));
+
+ if (this.client.getHabbo().getHabboInfo().getCurrentRoom() != null) {
+ this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(this.client.getHabbo()).compose());
+ }
return;
}
@@ -21,5 +28,10 @@ public class SetActivePrefixEvent extends MessageHandler {
this.client.getHabbo().getInventory().getPrefixesComponent().setActive(prefixId);
this.client.sendResponse(new ActivePrefixUpdatedComposer(prefix));
+ this.client.sendResponse(new UserNickIconsComposer(this.client.getHabbo()));
+
+ if (this.client.getHabbo().getHabboInfo().getCurrentRoom() != null) {
+ this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(this.client.getHabbo()).compose());
+ }
}
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetDisplayOrderEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetDisplayOrderEvent.java
new file mode 100644
index 00000000..b1406b15
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/inventory/prefixes/SetDisplayOrderEvent.java
@@ -0,0 +1,26 @@
+package com.eu.habbo.messages.incoming.inventory.prefixes;
+
+import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.habbohotel.users.inventory.UserVisualSettingsComponent;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.inventory.nickicons.UserNickIconsComposer;
+import com.eu.habbo.messages.outgoing.rooms.users.RoomUserDataComposer;
+
+public class SetDisplayOrderEvent extends MessageHandler {
+ @Override
+ public void handle() throws Exception {
+ Habbo habbo = this.client.getHabbo();
+
+ if (habbo == null || habbo.getInventory() == null || habbo.getInventory().getUserVisualSettingsComponent() == null) {
+ return;
+ }
+
+ String displayOrder = UserVisualSettingsComponent.sanitizeDisplayOrder(this.packet.readString());
+ habbo.getInventory().getUserVisualSettingsComponent().setDisplayOrder(displayOrder);
+ this.client.sendResponse(new UserNickIconsComposer(habbo));
+
+ if (habbo.getHabboInfo().getCurrentRoom() != null) {
+ habbo.getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(habbo).compose());
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/translation/TranslationLanguagesRequestEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/translation/TranslationLanguagesRequestEvent.java
new file mode 100644
index 00000000..6ec5bb91
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/translation/TranslationLanguagesRequestEvent.java
@@ -0,0 +1,28 @@
+package com.eu.habbo.messages.incoming.translation;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.gameclients.GameClient;
+import com.eu.habbo.habbohotel.translations.GoogleTranslateManager;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.translation.TranslationLanguagesComposer;
+
+public class TranslationLanguagesRequestEvent extends MessageHandler {
+ @Override
+ public void handle() {
+ final GameClient client = this.client;
+ final String displayLanguage = this.packet.readString();
+
+ Emulator.getThreading().run(() -> {
+ GoogleTranslateManager.SupportedLanguagesResponse response = Emulator.getGameEnvironment()
+ .getGoogleTranslateManager()
+ .getSupportedLanguages(displayLanguage);
+
+ client.sendResponse(new TranslationLanguagesComposer(response).compose());
+ });
+ }
+
+ @Override
+ public int getRatelimit() {
+ return 250;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/translation/TranslationTextRequestEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/translation/TranslationTextRequestEvent.java
new file mode 100644
index 00000000..798e97ce
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/translation/TranslationTextRequestEvent.java
@@ -0,0 +1,25 @@
+package com.eu.habbo.messages.incoming.translation;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.gameclients.GameClient;
+import com.eu.habbo.habbohotel.translations.GoogleTranslateManager;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.translation.TranslationResultComposer;
+
+public class TranslationTextRequestEvent extends MessageHandler {
+ @Override
+ public void handle() {
+ final GameClient client = this.client;
+ final int requestId = this.packet.readInt();
+ final String text = this.packet.readString();
+ final String targetLanguage = this.packet.readString();
+
+ Emulator.getThreading().run(() -> {
+ GoogleTranslateManager.TranslationResponse response = Emulator.getGameEnvironment()
+ .getGoogleTranslateManager()
+ .translate(text, targetLanguage);
+
+ client.sendResponse(new TranslationResultComposer(requestId, response).compose());
+ });
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wired/WiredEffectSaveDataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wired/WiredEffectSaveDataEvent.java
index 258e02ce..85728ba7 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/wired/WiredEffectSaveDataEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/wired/WiredEffectSaveDataEvent.java
@@ -9,6 +9,7 @@ import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.wired.core.WiredManager;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.generic.alerts.UpdateFailedComposer;
+import com.eu.habbo.messages.outgoing.rooms.items.ItemStateComposer;
import com.eu.habbo.messages.outgoing.wired.WiredSavedComposer;
public class WiredEffectSaveDataEvent extends MessageHandler {
@@ -39,6 +40,16 @@ public class WiredEffectSaveDataEvent extends MessageHandler {
if (saved) {
this.client.sendResponse(new WiredSavedComposer());
if (effect != null) {
+ if (effect.isSelector()) {
+ if (effect.usesExistingSelectorTargets()) {
+ effect.setExtradata("3");
+ room.sendComposer(new ItemStateComposer(effect).compose());
+ } else if ("3".equals(effect.getExtradata()) || "4".equals(effect.getExtradata()) || "5".equals(effect.getExtradata())) {
+ effect.setExtradata("0");
+ room.sendComposer(new ItemStateComposer(effect).compose());
+ }
+ }
+
effect.needsUpdate(true);
Emulator.getThreading().run(effect);
} else {
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 9a28a3a6..45d8c986 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
@@ -124,6 +124,8 @@ public class Outgoing {
public final static int WiredRoomSettingsDataComposer = 5102; // CUSTOM
public final static int WiredUserVariablesDataComposer = 5103; // CUSTOM
public final static int ConfInvisStateComposer = 5104; // CUSTOM
+ public final static int TranslationLanguagesComposer = 5106; // CUSTOM
+ public final static int TranslationResultComposer = 5107; // CUSTOM
public final static int AreaHideComposer = 6001; // CUSTOM
public final static int RoomPaintComposer = 2454; // PRODUCTION-201611291003-338511768
public final static int MarketplaceConfigComposer = 1823; // PRODUCTION-201611291003-338511768
@@ -576,6 +578,7 @@ public class Outgoing {
public static final int UserPrefixesComposer = 7001;
public static final int PrefixReceivedComposer = 7002;
public static final int ActivePrefixUpdatedComposer = 7003;
+ public static final int UserNickIconsComposer = 7004;
public static final int AvailableCommandsComposer = 4050;
// YouTube Room Broadcast
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/nickicons/UserNickIconsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/nickicons/UserNickIconsComposer.java
new file mode 100644
index 00000000..552a4fa9
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/nickicons/UserNickIconsComposer.java
@@ -0,0 +1,217 @@
+package com.eu.habbo.messages.outgoing.inventory.nickicons;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.habbohotel.users.UserCustomizationData;
+import com.eu.habbo.habbohotel.users.UserNickIcon;
+import com.eu.habbo.habbohotel.users.UserPrefix;
+import com.eu.habbo.messages.ServerMessage;
+import com.eu.habbo.messages.outgoing.MessageComposer;
+import com.eu.habbo.messages.outgoing.Outgoing;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class UserNickIconsComposer extends MessageComposer {
+ private static final Logger LOGGER = LoggerFactory.getLogger(UserNickIconsComposer.class);
+
+ private final Habbo habbo;
+
+ public UserNickIconsComposer(Habbo habbo) {
+ this.habbo = habbo;
+ }
+
+ @Override
+ protected ServerMessage composeInternal() {
+ this.response.init(Outgoing.UserNickIconsComposer);
+
+ if (this.habbo == null || this.habbo.getInventory() == null || this.habbo.getInventory().getNickIconsComponent() == null) {
+ this.response.appendInt(0);
+ return this.response;
+ }
+
+ Map ownedByKey = new HashMap<>();
+ List ownedNickIcons = this.habbo.getInventory().getNickIconsComponent().getNickIcons();
+
+ for (UserNickIcon nickIcon : ownedNickIcons) {
+ ownedByKey.put(nickIcon.getIconKey().toLowerCase(), nickIcon);
+ }
+
+ Map ownedPrefixesByCatalogId = new HashMap<>();
+ List ownedPrefixes = this.habbo.getInventory().getPrefixesComponent().getPrefixes();
+
+ for (UserPrefix prefix : ownedPrefixes) {
+ if (prefix.getCatalogPrefixId() > 0) {
+ ownedPrefixesByCatalogId.put(prefix.getCatalogPrefixId(), prefix);
+ }
+ }
+
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT icon_key, display_name, points, points_type FROM custom_nick_icons_catalog WHERE enabled = 1 ORDER BY sort_order ASC, id ASC")) {
+ try (ResultSet set = statement.executeQuery()) {
+ List catalogNickIcons = new ArrayList<>();
+
+ while (set.next()) {
+ catalogNickIcons.add(new CatalogNickIcon(
+ set.getString("icon_key"),
+ set.getString("display_name"),
+ set.getInt("points"),
+ set.getInt("points_type")));
+ }
+
+ this.response.appendInt(catalogNickIcons.size());
+
+ for (CatalogNickIcon catalogNickIcon : catalogNickIcons) {
+ UserNickIcon ownedNickIcon = ownedByKey.get(catalogNickIcon.iconKey.toLowerCase());
+
+ this.response.appendString(catalogNickIcon.iconKey);
+ this.response.appendString(catalogNickIcon.displayName != null ? catalogNickIcon.displayName : "");
+ this.response.appendInt(catalogNickIcon.points);
+ this.response.appendInt(catalogNickIcon.pointsType);
+ this.response.appendInt(ownedNickIcon != null ? 1 : 0);
+ this.response.appendInt((ownedNickIcon != null && ownedNickIcon.isActive()) ? 1 : 0);
+ this.response.appendInt(ownedNickIcon != null ? ownedNickIcon.getId() : 0);
+ }
+ }
+ } catch (SQLException e) {
+ LOGGER.error("Caught SQL exception", e);
+ this.response.appendInt(0);
+ }
+
+ UserCustomizationData customizationData = UserCustomizationData.fromHabbo(this.habbo);
+ this.response.appendString(customizationData.displayOrder);
+ this.response.appendInt(this.getSettingInt("max_length", 15));
+ this.response.appendInt(this.getSettingInt("price_credits", 5));
+ this.response.appendInt(this.getSettingInt("price_points", 0));
+ this.response.appendInt(this.getSettingInt("points_type", 0));
+ this.response.appendInt(this.getSettingInt("font_price_credits", 10));
+ this.response.appendInt(this.getSettingInt("font_price_points", 0));
+ this.response.appendInt(this.getSettingInt("font_points_type", 0));
+
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT id, display_name, text, color, icon, effect, font, points, points_type FROM custom_prefixes_catalog WHERE enabled = 1 ORDER BY sort_order ASC, id ASC")) {
+ try (ResultSet set = statement.executeQuery()) {
+ List catalogPrefixes = new ArrayList<>();
+
+ while (set.next()) {
+ catalogPrefixes.add(new CatalogPrefix(
+ set.getInt("id"),
+ set.getString("display_name"),
+ set.getString("text"),
+ set.getString("color"),
+ set.getString("icon"),
+ set.getString("effect"),
+ set.getString("font"),
+ set.getInt("points"),
+ set.getInt("points_type")));
+ }
+
+ this.response.appendInt(catalogPrefixes.size());
+
+ for (CatalogPrefix catalogPrefix : catalogPrefixes) {
+ UserPrefix ownedPrefix = ownedPrefixesByCatalogId.get(catalogPrefix.id);
+
+ this.response.appendInt(catalogPrefix.id);
+ this.response.appendString(catalogPrefix.displayName != null ? catalogPrefix.displayName : catalogPrefix.text);
+ this.response.appendString(catalogPrefix.text != null ? catalogPrefix.text : "");
+ this.response.appendString(catalogPrefix.color != null ? catalogPrefix.color : "");
+ this.response.appendString(catalogPrefix.icon != null ? catalogPrefix.icon : "");
+ this.response.appendString(catalogPrefix.effect != null ? catalogPrefix.effect : "");
+ this.response.appendString(catalogPrefix.font != null ? catalogPrefix.font : "");
+ this.response.appendInt(catalogPrefix.points);
+ this.response.appendInt(catalogPrefix.pointsType);
+ this.response.appendInt(ownedPrefix != null ? 1 : 0);
+ this.response.appendInt((ownedPrefix != null && ownedPrefix.isActive()) ? 1 : 0);
+ this.response.appendInt(ownedPrefix != null ? ownedPrefix.getId() : 0);
+ }
+ }
+ } catch (SQLException e) {
+ LOGGER.error("Caught SQL exception loading prefix catalog", e);
+ this.response.appendInt(0);
+ }
+
+ this.response.appendInt(ownedPrefixes.size());
+
+ for (UserPrefix prefix : ownedPrefixes) {
+ this.response.appendInt(prefix.getId());
+ this.response.appendString(prefix.getDisplayName() != null ? prefix.getDisplayName() : prefix.getText());
+ this.response.appendString(prefix.getText());
+ this.response.appendString(prefix.getColor());
+ this.response.appendString(prefix.getIcon());
+ this.response.appendString(prefix.getEffect());
+ this.response.appendString(prefix.getFont());
+ this.response.appendInt(prefix.isActive() ? 1 : 0);
+ this.response.appendInt(prefix.isCustom() ? 1 : 0);
+ this.response.appendInt(prefix.getPoints());
+ this.response.appendInt(prefix.getPointsType());
+ this.response.appendInt(prefix.getCatalogPrefixId());
+ }
+
+ return this.response;
+ }
+
+ private int getSettingInt(String key, int defaultValue) {
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement("SELECT `value` FROM custom_prefix_settings WHERE key_name = ? LIMIT 1")) {
+ statement.setString(1, key);
+
+ try (ResultSet set = statement.executeQuery()) {
+ if (set.next()) {
+ return Integer.parseInt(set.getString("value"));
+ }
+ }
+ } catch (SQLException | NumberFormatException e) {
+ LOGGER.error("Caught exception while resolving prefix setting {}", key, e);
+ }
+
+ return defaultValue;
+ }
+
+ private static class CatalogNickIcon {
+ private final String iconKey;
+ private final String displayName;
+ private final int points;
+ private final int pointsType;
+
+ private CatalogNickIcon(String iconKey, String displayName, int points, int pointsType) {
+ this.iconKey = iconKey;
+ this.displayName = displayName;
+ this.points = points;
+ this.pointsType = pointsType;
+ }
+ }
+
+ private static class CatalogPrefix {
+ private final int id;
+ private final String displayName;
+ private final String text;
+ private final String color;
+ private final String icon;
+ private final String effect;
+ private final String font;
+ private final int points;
+ private final int pointsType;
+
+ private CatalogPrefix(int id, String displayName, String text, String color, String icon, String effect, String font, int points, int pointsType) {
+ this.id = id;
+ this.displayName = displayName;
+ this.text = text;
+ this.color = color;
+ this.icon = icon;
+ this.effect = effect;
+ this.font = font;
+ this.points = points;
+ this.pointsType = pointsType;
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/ActivePrefixUpdatedComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/ActivePrefixUpdatedComposer.java
index 13017e93..b78977e8 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/ActivePrefixUpdatedComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/ActivePrefixUpdatedComposer.java
@@ -22,12 +22,14 @@ public class ActivePrefixUpdatedComposer extends MessageComposer {
this.response.appendString(this.prefix.getColor());
this.response.appendString(this.prefix.getIcon());
this.response.appendString(this.prefix.getEffect());
+ this.response.appendString(this.prefix.getFont());
} else {
this.response.appendInt(0);
this.response.appendString("");
this.response.appendString("");
this.response.appendString("");
this.response.appendString("");
+ this.response.appendString("");
}
return this.response;
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/PrefixReceivedComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/PrefixReceivedComposer.java
index 98bdf055..6db2effe 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/PrefixReceivedComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/PrefixReceivedComposer.java
@@ -20,6 +20,7 @@ public class PrefixReceivedComposer extends MessageComposer {
this.response.appendString(this.prefix.getColor());
this.response.appendString(this.prefix.getIcon());
this.response.appendString(this.prefix.getEffect());
+ this.response.appendString(this.prefix.getFont());
return this.response;
}
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/UserPrefixesComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/UserPrefixesComposer.java
index 747e63b6..c75c2fe2 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/UserPrefixesComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/inventory/prefixes/UserPrefixesComposer.java
@@ -30,6 +30,7 @@ public class UserPrefixesComposer extends MessageComposer {
this.response.appendString(prefix.getColor());
this.response.appendString(prefix.getIcon());
this.response.appendString(prefix.getEffect());
+ this.response.appendString(prefix.getFont());
this.response.appendInt(prefix.isActive() ? 1 : 0);
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java
index 755fdcc9..7162dbf0 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java
@@ -1,6 +1,7 @@
package com.eu.habbo.messages.outgoing.rooms.users;
import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.habbohotel.users.UserCustomizationData;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
@@ -24,6 +25,14 @@ public class RoomUserDataComposer extends MessageComposer {
this.response.appendInt(this.habbo.getHabboInfo().getInfostandStand());
this.response.appendInt(this.habbo.getHabboInfo().getInfostandOverlay());
this.response.appendInt(this.habbo.getHabboInfo().getInfostandCardBg());
+ UserCustomizationData customizationData = UserCustomizationData.fromHabbo(this.habbo);
+ this.response.appendString(customizationData.nickIcon);
+ this.response.appendString(customizationData.prefixText);
+ this.response.appendString(customizationData.prefixColor);
+ this.response.appendString(customizationData.prefixIcon);
+ this.response.appendString(customizationData.prefixEffect);
+ this.response.appendString(customizationData.prefixFont);
+ this.response.appendString(customizationData.displayOrder);
return this.response;
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java
index cf878af8..796935c2 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java
@@ -4,6 +4,7 @@ import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.bots.Bot;
import com.eu.habbo.habbohotel.guilds.Guild;
import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.habbohotel.users.UserCustomizationData;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
@@ -67,6 +68,14 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendString("");
this.response.appendInt(this.habbo.getHabboStats().getAchievementScore());
this.response.appendBoolean(true);
+ UserCustomizationData customizationData = UserCustomizationData.fromHabbo(this.habbo);
+ this.response.appendString(customizationData.nickIcon);
+ this.response.appendString(customizationData.prefixText);
+ this.response.appendString(customizationData.prefixColor);
+ this.response.appendString(customizationData.prefixIcon);
+ this.response.appendString(customizationData.prefixEffect);
+ this.response.appendString(customizationData.prefixFont);
+ this.response.appendString(customizationData.displayOrder);
this.response.appendString(this.habbo.getHabboInfo().getRoomEntryMethod());
this.response.appendInt(this.habbo.getHabboInfo().getRoomEntryTeleportId());
} else if (this.habbos != null) {
@@ -101,6 +110,14 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendString("");
this.response.appendInt(habbo.getHabboStats().getAchievementScore());
this.response.appendBoolean(true);
+ UserCustomizationData customizationData = UserCustomizationData.fromHabbo(habbo);
+ this.response.appendString(customizationData.nickIcon);
+ this.response.appendString(customizationData.prefixText);
+ this.response.appendString(customizationData.prefixColor);
+ this.response.appendString(customizationData.prefixIcon);
+ this.response.appendString(customizationData.prefixEffect);
+ this.response.appendString(customizationData.prefixFont);
+ this.response.appendString(customizationData.displayOrder);
this.response.appendString(habbo.getHabboInfo().getRoomEntryMethod());
this.response.appendInt(habbo.getHabboInfo().getRoomEntryTeleportId());
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/translation/TranslationLanguagesComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/translation/TranslationLanguagesComposer.java
new file mode 100644
index 00000000..63d9de56
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/translation/TranslationLanguagesComposer.java
@@ -0,0 +1,33 @@
+package com.eu.habbo.messages.outgoing.translation;
+
+import com.eu.habbo.habbohotel.translations.GoogleTranslateManager;
+import com.eu.habbo.messages.ServerMessage;
+import com.eu.habbo.messages.outgoing.MessageComposer;
+import com.eu.habbo.messages.outgoing.Outgoing;
+
+public class TranslationLanguagesComposer extends MessageComposer {
+ private final GoogleTranslateManager.SupportedLanguagesResponse responseData;
+
+ public TranslationLanguagesComposer(GoogleTranslateManager.SupportedLanguagesResponse responseData) {
+ this.responseData = responseData;
+ }
+
+ @Override
+ protected ServerMessage composeInternal() {
+ this.response.init(Outgoing.TranslationLanguagesComposer);
+ this.response.appendBoolean(this.responseData != null && this.responseData.isSuccess());
+ this.response.appendString(this.responseData != null ? this.responseData.getErrorMessage() : "Unknown error");
+
+ int count = (this.responseData != null) ? this.responseData.getLanguages().size() : 0;
+ this.response.appendInt(count);
+
+ if (this.responseData != null) {
+ for (GoogleTranslateManager.SupportedLanguage language : this.responseData.getLanguages()) {
+ this.response.appendString(language.getCode());
+ this.response.appendString(language.getName());
+ }
+ }
+
+ return this.response;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/translation/TranslationResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/translation/TranslationResultComposer.java
new file mode 100644
index 00000000..662e81d5
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/translation/TranslationResultComposer.java
@@ -0,0 +1,29 @@
+package com.eu.habbo.messages.outgoing.translation;
+
+import com.eu.habbo.habbohotel.translations.GoogleTranslateManager;
+import com.eu.habbo.messages.ServerMessage;
+import com.eu.habbo.messages.outgoing.MessageComposer;
+import com.eu.habbo.messages.outgoing.Outgoing;
+
+public class TranslationResultComposer extends MessageComposer {
+ private final int requestId;
+ private final GoogleTranslateManager.TranslationResponse responseData;
+
+ public TranslationResultComposer(int requestId, GoogleTranslateManager.TranslationResponse responseData) {
+ this.requestId = requestId;
+ this.responseData = responseData;
+ }
+
+ @Override
+ protected ServerMessage composeInternal() {
+ this.response.init(Outgoing.TranslationResultComposer);
+ this.response.appendInt(this.requestId);
+ this.response.appendBoolean(this.responseData != null && this.responseData.isSuccess());
+ this.response.appendString(this.responseData != null ? this.responseData.getErrorMessage() : "Unknown error");
+ this.response.appendString(this.responseData != null ? this.responseData.getOriginalText() : "");
+ this.response.appendString(this.responseData != null ? this.responseData.getTranslatedText() : "");
+ this.response.appendString(this.responseData != null ? this.responseData.getDetectedLanguage() : "");
+ this.response.appendString(this.responseData != null ? this.responseData.getTargetLanguage() : "");
+ return this.response;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserProfileComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserProfileComposer.java
index 0a6fb0e7..e56326d4 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserProfileComposer.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserProfileComposer.java
@@ -6,6 +6,7 @@ import com.eu.habbo.habbohotel.guilds.Guild;
import com.eu.habbo.habbohotel.messenger.Messenger;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboInfo;
+import com.eu.habbo.habbohotel.users.UserCustomizationData;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
@@ -116,6 +117,14 @@ public class UserProfileComposer extends MessageComposer {
this.response.appendInt(this.habboInfo.getInfostandStand());
this.response.appendInt(this.habboInfo.getInfostandOverlay());
this.response.appendInt(this.habboInfo.getInfostandCardBg());
+ UserCustomizationData customizationData = (this.habbo != null) ? UserCustomizationData.fromHabbo(this.habbo) : UserCustomizationData.fromUserId(this.habboInfo.getId());
+ this.response.appendString(customizationData.nickIcon);
+ this.response.appendString(customizationData.prefixText);
+ this.response.appendString(customizationData.prefixColor);
+ this.response.appendString(customizationData.prefixIcon);
+ this.response.appendString(customizationData.prefixEffect);
+ this.response.appendString(customizationData.prefixFont);
+ this.response.appendString(customizationData.displayOrder);
return this.response;
}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/Server.java b/Emulator/src/main/java/com/eu/habbo/networking/Server.java
index f7a4a3ce..7ae6f43f 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/Server.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/Server.java
@@ -92,4 +92,4 @@ public abstract class Server {
public int getPort() {
return this.port;
}
-}
\ No newline at end of file
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
index 4558d9af..facfb285 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
@@ -3,6 +3,8 @@ package com.eu.habbo.networking.gameserver;
import com.eu.habbo.Emulator;
import com.eu.habbo.messages.PacketManager;
import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
+import com.eu.habbo.networking.gameserver.auth.NitroSecureApiHandler;
+import com.eu.habbo.networking.gameserver.auth.NitroSecureAssetHandler;
import com.eu.habbo.networking.gameserver.badges.BadgeHttpHandler;
import com.eu.habbo.networking.gameserver.codec.WebSocketCodec;
import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
@@ -53,6 +55,8 @@ public class WebSocketChannelInitializer extends ChannelInitializer> SECURE_CONTEXTS =
+ AttributeKey.valueOf("nitroSecureApiContexts");
+ private static final ConcurrentHashMap NONCE_CACHE = new ConcurrentHashMap<>();
+ private static final long MAX_REQUEST_SKEW_MS = 90_000L;
+ private static final long NONCE_TTL_MS = 2 * 60 * 1000L;
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (!(msg instanceof FullHttpRequest req)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ String path = new QueryStringDecoder(req.uri()).path();
+
+ if (!secureApiEnabled()) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ if (!path.startsWith(API_PREFIX)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ if (req.method() == HttpMethod.OPTIONS) {
+ sendCors(ctx, req);
+ ReferenceCountUtil.release(req);
+ return;
+ }
+
+ if (!isSecureRequest(req)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ try {
+ String clientKey = req.headers().get("X-Nitro-Key");
+ if (clientKey == null || clientKey.isBlank()) {
+ sendText(ctx, req, HttpResponseStatus.UNAUTHORIZED, "Missing key.");
+ return;
+ }
+
+ SecretKey sessionKey = NitroSecureAssetHandler.deriveSessionKey(java.util.Base64.getDecoder().decode(clientKey));
+ SecureApiContext secureContext = new SecureApiContext(
+ NitroSecureAssetHandler.getServerKeyFingerprint(),
+ NitroSecureAssetHandler.fingerprint(sessionKey.getEncoded()),
+ sessionKey
+ );
+
+ if (!req.content().isReadable()) {
+ enqueueContext(ctx, secureContext);
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ byte[] encrypted = new byte[req.content().readableBytes()];
+ req.content().getBytes(req.content().readerIndex(), encrypted);
+ byte[] clear = NitroSecureAssetHandler.decrypt(sessionKey, NitroSecureAssetHandler.fromHex(new String(encrypted, StandardCharsets.UTF_8)));
+ clear = unwrapEnvelope(clear, req, secureContext);
+
+ FullHttpRequest decryptedReq = new DefaultFullHttpRequest(
+ req.protocolVersion(),
+ req.method(),
+ req.uri(),
+ Unpooled.wrappedBuffer(clear)
+ );
+
+ decryptedReq.headers().setAll(req.headers());
+ decryptedReq.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
+ decryptedReq.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, clear.length);
+
+ enqueueContext(ctx, secureContext);
+ ReferenceCountUtil.release(req);
+ ctx.fireChannelRead(decryptedReq);
+ } catch (IllegalArgumentException e) {
+ LOGGER.warn("Nitro secure API rejected invalid encrypted payload", e);
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, e.getMessage());
+ ReferenceCountUtil.release(req);
+ } catch (Exception e) {
+ LOGGER.error("Nitro secure API failed to decrypt request", e);
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Invalid secure payload.");
+ ReferenceCountUtil.release(req);
+ }
+ }
+
+ @Override
+ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
+ if (!(msg instanceof FullHttpResponse response)) {
+ super.write(ctx, msg, promise);
+ return;
+ }
+
+ SecureApiContext secureContext = pollContext(ctx);
+ if (secureContext == null) {
+ super.write(ctx, msg, promise);
+ return;
+ }
+
+ try {
+ byte[] clear = readBytes(response.content());
+ byte[] encrypted = NitroSecureAssetHandler.encrypt(secureContext.sessionKey(), clear);
+ byte[] hex = NitroSecureAssetHandler.toHex(encrypted).getBytes(StandardCharsets.UTF_8);
+
+ FullHttpResponse encryptedResponse = new DefaultFullHttpResponse(
+ response.protocolVersion(),
+ response.status(),
+ Unpooled.wrappedBuffer(hex)
+ );
+
+ encryptedResponse.headers().setAll(response.headers());
+ encryptedResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
+ encryptedResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, hex.length);
+ encryptedResponse.headers().set("X-Nitro-Sec", "1");
+ encryptedResponse.headers().set("X-Nitro-Key-Fp", secureContext.serverKeyFingerprint());
+ encryptedResponse.headers().set("X-Nitro-Derive-Fp", secureContext.derivedFingerprint());
+ encryptedResponse.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
+
+ ReferenceCountUtil.release(response);
+ super.write(ctx, encryptedResponse, promise);
+ } catch (Exception e) {
+ LOGGER.error("Nitro secure API failed to encrypt response", e);
+ super.write(ctx, msg, promise);
+ }
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ Deque contexts = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (contexts != null) contexts.clear();
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
+ Deque contexts = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (contexts != null) contexts.clear();
+ super.exceptionCaught(ctx, cause);
+ }
+
+ private static boolean isSecureRequest(FullHttpRequest req) {
+ return "1".equals(req.headers().get("X-Nitro-Api"));
+ }
+
+ private static boolean secureApiEnabled() {
+ return com.eu.habbo.Emulator.getConfig().getBoolean(ENABLED_CONFIG, true);
+ }
+
+ private static byte[] unwrapEnvelope(byte[] clear, FullHttpRequest req, SecureApiContext secureContext) {
+ if (!requiresReplayEnvelope(req.method())) return clear;
+
+ JsonObject envelope = JsonParser.parseString(new String(clear, StandardCharsets.UTF_8)).getAsJsonObject();
+ long ts = envelope.has("ts") ? envelope.get("ts").getAsLong() : 0L;
+ String nonce = envelope.has("nonce") ? envelope.get("nonce").getAsString() : "";
+ String method = envelope.has("method") ? envelope.get("method").getAsString() : "";
+ String path = envelope.has("path") ? envelope.get("path").getAsString() : "";
+ String body = envelope.has("body") ? envelope.get("body").getAsString() : "";
+ long now = System.currentTimeMillis();
+
+ if (Math.abs(now - ts) > MAX_REQUEST_SKEW_MS) {
+ throw new IllegalArgumentException("Secure request expired.");
+ }
+
+ if (!req.method().name().equalsIgnoreCase(method)) {
+ throw new IllegalArgumentException("Secure request method mismatch.");
+ }
+
+ String requestPath = req.uri();
+ if (!requestPath.equals(path)) {
+ throw new IllegalArgumentException("Secure request path mismatch.");
+ }
+
+ if (nonce.isBlank()) {
+ throw new IllegalArgumentException("Missing secure request nonce.");
+ }
+
+ cleanupExpiredNonces(now);
+
+ String replayKey = secureContext.derivedFingerprint() + ':' + nonce;
+ if (NONCE_CACHE.putIfAbsent(replayKey, now + NONCE_TTL_MS) != null) {
+ throw new IllegalArgumentException("Secure request replay detected.");
+ }
+
+ return java.util.Base64.getDecoder().decode(body);
+ }
+
+ private static boolean requiresReplayEnvelope(HttpMethod method) {
+ return method == HttpMethod.POST
+ || method == HttpMethod.PUT
+ || method == HttpMethod.PATCH
+ || method == HttpMethod.DELETE;
+ }
+
+ private static void cleanupExpiredNonces(long now) {
+ if (NONCE_CACHE.size() < 512) return;
+ NONCE_CACHE.entrySet().removeIf(entry -> entry.getValue() < now);
+ }
+
+ private static void enqueueContext(ChannelHandlerContext ctx, SecureApiContext context) {
+ Deque queue = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (queue == null) {
+ queue = new ArrayDeque<>();
+ ctx.channel().attr(SECURE_CONTEXTS).set(queue);
+ }
+
+ queue.addLast(context);
+ }
+
+ private static SecureApiContext pollContext(ChannelHandlerContext ctx) {
+ Deque queue = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (queue == null || queue.isEmpty()) return null;
+ return queue.pollFirst();
+ }
+
+ private static byte[] readBytes(ByteBuf content) {
+ byte[] bytes = new byte[content.readableBytes()];
+ content.getBytes(content.readerIndex(), bytes);
+ return bytes;
+ }
+
+ private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
+ applyCors(req, response);
+ ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void sendText(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String text) {
+ byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
+ response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
+ response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
+ applyCors(req, response);
+ boolean keepAlive = isKeepAlive(req);
+ if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ var future = ctx.writeAndFlush(response);
+ if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
+ String origin = req.headers().get(HttpHeaderNames.ORIGIN);
+ if (origin != null && !origin.isEmpty()) {
+ response.headers().set("Access-Control-Allow-Origin", origin);
+ response.headers().set("Vary", "Origin");
+ response.headers().set("Access-Control-Allow-Credentials", "true");
+ }
+ response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
+ response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
+ response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
+ }
+
+ private static boolean isKeepAlive(FullHttpRequest req) {
+ String connection = req.headers().get(HttpHeaderNames.CONNECTION);
+ return connection == null || !"close".equalsIgnoreCase(connection);
+ }
+
+ private record SecureApiContext(String serverKeyFingerprint, String derivedFingerprint, SecretKey sessionKey) {
+ private SecureApiContext {
+ Objects.requireNonNull(serverKeyFingerprint);
+ Objects.requireNonNull(derivedFingerprint);
+ Objects.requireNonNull(sessionKey);
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java
new file mode 100644
index 00000000..a2da7b4e
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java
@@ -0,0 +1,355 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.http.*;
+import io.netty.util.ReferenceCountUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyAgreement;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.*;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter {
+ private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureAssetHandler.class);
+ private static final String MASTER_KEY_CONFIG = "nitro.secure.master_key";
+ private static final String ENABLED_CONFIG = "nitro.secure.assets.enabled";
+ private static final String BOOTSTRAP_PATH = "/nitro-sec/bootstrap";
+ private static final String FILE_PATH = "/nitro-sec/file";
+ private static final int MAX_BOOTSTRAP_BODY_BYTES = 4096;
+ private static final SecureRandom RNG = new SecureRandom();
+ private static final KeyPair SERVER_KEYPAIR = createServerKeyPair();
+ private static final String SERVER_KEY_FINGERPRINT = fingerprint(SERVER_KEYPAIR.getPublic().getEncoded());
+ private static final Map CACHE = new ConcurrentHashMap<>();
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (!(msg instanceof FullHttpRequest req)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ String path = new QueryStringDecoder(req.uri()).path();
+
+ if (!secureAssetsEnabled()) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ if (!path.equals(BOOTSTRAP_PATH) && !path.equals(FILE_PATH)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ try {
+ if (req.method() == HttpMethod.OPTIONS) {
+ sendCors(ctx, req);
+ return;
+ }
+
+ if (path.equals(BOOTSTRAP_PATH)) handleBootstrap(ctx, req);
+ else handleFile(ctx, req);
+ } finally {
+ ReferenceCountUtil.release(req);
+ }
+ }
+
+ private void handleBootstrap(ChannelHandlerContext ctx, FullHttpRequest req) {
+ if (req.method() != HttpMethod.POST) {
+ sendText(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, "Use POST.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ if (req.content().readableBytes() > MAX_BOOTSTRAP_BODY_BYTES) {
+ sendText(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, "Payload too large.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ try {
+ JsonObject body = JsonParser.parseString(req.content().toString(StandardCharsets.UTF_8)).getAsJsonObject();
+ String clientKey = body.has("key") ? body.get("key").getAsString() : "";
+ if (clientKey.isEmpty()) {
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Missing key.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ JsonObject response = new JsonObject();
+ response.addProperty("key", Base64.getEncoder().encodeToString(SERVER_KEYPAIR.getPublic().getEncoded()));
+ sendText(ctx, req, HttpResponseStatus.OK, response.toString(), "application/json; charset=utf-8");
+ } catch (Exception e) {
+ LOGGER.warn("Nitro secure bootstrap failed", e);
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Invalid bootstrap.", "text/plain; charset=utf-8");
+ }
+ }
+
+ private void handleFile(ChannelHandlerContext ctx, FullHttpRequest req) {
+ if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
+ sendText(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, "Use GET.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ QueryStringDecoder query = new QueryStringDecoder(req.uri());
+ String clientKey = headerOrQuery(req, query, "X-Nitro-Key", "key");
+ if (clientKey == null || clientKey.isEmpty()) {
+ sendText(ctx, req, HttpResponseStatus.UNAUTHORIZED, "Missing key.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ String kind = queryParam(query, "kind");
+ String file = queryParam(query, "file");
+ if (!kind.equals("config") && !kind.equals("gamedata")) {
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Invalid kind.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ try {
+ SecretKey sessionKey = deriveSessionKey(Base64.getDecoder().decode(clientKey));
+ byte[] clear = readAsset(kind, file);
+ byte[] encrypted = encrypt(sessionKey, clear);
+ sendText(ctx, req, HttpResponseStatus.OK, toHex(encrypted), "text/plain; charset=utf-8", true, fingerprint(sessionKey.getEncoded()));
+ } catch (IllegalArgumentException e) {
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, e.getMessage(), "text/plain; charset=utf-8");
+ } catch (IOException e) {
+ sendText(ctx, req, HttpResponseStatus.NOT_FOUND, "Not found.", "text/plain; charset=utf-8");
+ } catch (Exception e) {
+ LOGGER.error("Nitro secure asset failed kind=" + kind + " file=" + file, e);
+ sendText(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, "Server error.", "text/plain; charset=utf-8");
+ }
+ }
+
+ private static byte[] readAsset(String kind, String file) throws IOException {
+ String normalized = normalizeFile(file);
+ String rootConfigKey = kind.equals("config") ? "nitro.secure.config.root" : "nitro.secure.gamedata.root";
+ String fallback = kind.equals("config") ? "Nitro-V3/public" : "Nitro-V3/public/nitro/gamedata";
+ Path root = resolveRoot(rootConfigKey, fallback, kind.equals("config")
+ ? new String[] { "../Nitro-V3/public", "../../Nitro-V3/public", "Nitro-V3/public" }
+ : new String[] { "../Nitro-V3/public/nitro/gamedata", "../../Nitro-V3/public/nitro/gamedata", "Nitro-V3/public/nitro/gamedata" });
+ Path target = root.resolve(normalized).normalize();
+
+ if (!target.startsWith(root)) throw new IllegalArgumentException("Invalid file.");
+ if (!Files.isRegularFile(target)) throw new IOException("Not found");
+
+ String cacheKey = kind + ":" + target;
+ long modified = Files.getLastModifiedTime(target).toMillis();
+ CacheEntry cached = CACHE.get(cacheKey);
+ if (cached != null && cached.modified == modified) return cached.bytes;
+
+ byte[] bytes = Files.readAllBytes(target);
+ if (normalized.toLowerCase().endsWith(".json")) bytes = minifyJson(bytes);
+ CACHE.put(cacheKey, new CacheEntry(modified, bytes));
+ return bytes;
+ }
+
+ private static String normalizeFile(String file) {
+ if (file == null) throw new IllegalArgumentException("Missing file.");
+ String value = URLDecoder.decode(file, StandardCharsets.UTF_8).replace('\\', '/');
+ int queryIndex = value.indexOf('?');
+ if (queryIndex >= 0) value = value.substring(0, queryIndex);
+ int fragmentIndex = value.indexOf('#');
+ if (fragmentIndex >= 0) value = value.substring(0, fragmentIndex);
+ while (value.startsWith("/")) value = value.substring(1);
+ if (value.isEmpty() || value.contains("..") || value.contains(":")) throw new IllegalArgumentException("Invalid file.");
+ return value;
+ }
+
+ private static byte[] minifyJson(byte[] bytes) {
+ try {
+ return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).toString().getBytes(StandardCharsets.UTF_8);
+ } catch (Exception ignored) {
+ return bytes;
+ }
+ }
+
+ private static Path resolveRoot(String configKey, String fallback, String[] alternatives) {
+ String configured = Emulator.getConfig().getValue(configKey, "");
+ if (configured != null && !configured.isEmpty()) return Path.of(configured).toAbsolutePath().normalize();
+
+ for (String alternative : alternatives) {
+ Path path = Path.of(alternative).toAbsolutePath().normalize();
+ if (Files.isDirectory(path)) return path;
+ }
+
+ return Path.of(fallback).toAbsolutePath().normalize();
+ }
+
+ private static boolean secureAssetsEnabled() {
+ return Emulator.getConfig().getBoolean(ENABLED_CONFIG, true);
+ }
+
+ static SecretKey deriveSessionKey(byte[] clientPublicEncoded) throws Exception {
+ KeyFactory factory = KeyFactory.getInstance("EC");
+ PublicKey clientPublic = factory.generatePublic(new X509EncodedKeySpec(clientPublicEncoded));
+ KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
+ agreement.init(SERVER_KEYPAIR.getPrivate());
+ agreement.doPhase(clientPublic, true);
+ byte[] secret = agreement.generateSecret();
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(secret);
+ digest.update("nitro-secure-assets-v1".getBytes(StandardCharsets.UTF_8));
+ return new SecretKeySpec(digest.digest(), "AES");
+ }
+
+ static byte[] encrypt(SecretKey key, byte[] clear) throws Exception {
+ byte[] iv = new byte[12];
+ RNG.nextBytes(iv);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
+ byte[] encrypted = cipher.doFinal(clear);
+ byte[] out = new byte[iv.length + encrypted.length];
+ System.arraycopy(iv, 0, out, 0, iv.length);
+ System.arraycopy(encrypted, 0, out, iv.length, encrypted.length);
+ return out;
+ }
+
+ static byte[] decrypt(SecretKey key, byte[] encryptedPayload) throws Exception {
+ if (encryptedPayload.length < 13) throw new IllegalArgumentException("Encrypted payload is too short.");
+ byte[] iv = new byte[12];
+ byte[] payload = new byte[encryptedPayload.length - iv.length];
+ System.arraycopy(encryptedPayload, 0, iv, 0, iv.length);
+ System.arraycopy(encryptedPayload, iv.length, payload, 0, payload.length);
+
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
+ return cipher.doFinal(payload);
+ }
+
+ private static KeyPair createServerKeyPair() {
+ try {
+ String configuredSecret = Emulator.getConfig().getValue(MASTER_KEY_CONFIG, "");
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ if (configuredSecret != null && !configuredSecret.isBlank()) {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] seed = digest.digest(configuredSecret.getBytes(StandardCharsets.UTF_8));
+ SecureRandom deterministic = SecureRandom.getInstance("SHA1PRNG");
+ deterministic.setSeed(seed);
+ generator.initialize(256, deterministic);
+ LOGGER.info("Nitro secure assets using persistent server key from config {}", MASTER_KEY_CONFIG);
+ } else {
+ generator.initialize(256, RNG);
+ LOGGER.warn("Nitro secure assets using ephemeral server key because {} is empty", MASTER_KEY_CONFIG);
+ }
+ return generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalStateException("Unable to create Nitro secure server key", e);
+ }
+ }
+
+ private static String headerOrQuery(FullHttpRequest req, QueryStringDecoder query, String header, String param) {
+ String value = req.headers().get(header);
+ return (value == null || value.isEmpty()) ? queryParam(query, param) : value;
+ }
+
+ private static String queryParam(QueryStringDecoder query, String key) {
+ if (!query.parameters().containsKey(key) || query.parameters().get(key).isEmpty()) return "";
+ return query.parameters().get(key).get(0);
+ }
+
+ private static void sendText(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String text, String contentType) {
+ sendBytes(ctx, req, status, text.getBytes(StandardCharsets.UTF_8), contentType, false, null);
+ }
+
+ private static void sendText(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String text, String contentType, boolean encrypted, String deriveFingerprint) {
+ sendBytes(ctx, req, status, text.getBytes(StandardCharsets.UTF_8), contentType, encrypted, deriveFingerprint);
+ }
+
+ private static void sendBytes(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, byte[] bytes, String contentType, boolean encrypted) {
+ sendBytes(ctx, req, status, bytes, contentType, encrypted, null);
+ }
+
+ private static void sendBytes(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, byte[] bytes, String contentType, boolean encrypted, String deriveFingerprint) {
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
+ response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType);
+ response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
+ response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-store, no-cache, must-revalidate");
+ if (encrypted) response.headers().set("X-Nitro-Sec", "1");
+ response.headers().set("X-Nitro-Key-Fp", SERVER_KEY_FINGERPRINT);
+ if (deriveFingerprint != null && !deriveFingerprint.isEmpty()) response.headers().set("X-Nitro-Derive-Fp", deriveFingerprint);
+ applyCors(req, response);
+ boolean keepAlive = isKeepAlive(req);
+ if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ var future = ctx.writeAndFlush(response);
+ if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
+ applyCors(req, response);
+ ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
+ String origin = req.headers().get(HttpHeaderNames.ORIGIN);
+ if (origin != null && !origin.isEmpty()) {
+ response.headers().set("Access-Control-Allow-Origin", origin);
+ response.headers().set("Vary", "Origin");
+ }
+ response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
+ response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Nitro-Key");
+ response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
+ }
+
+ private static boolean isKeepAlive(FullHttpRequest req) {
+ String connection = req.headers().get(HttpHeaderNames.CONNECTION);
+ return connection == null || !"close".equalsIgnoreCase(connection);
+ }
+
+ static String fingerprint(byte[] bytes) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(bytes);
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < 8 && i < hash.length; i++) {
+ builder.append(String.format("%02x", hash[i]));
+ }
+ return builder.toString();
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+
+ static String getServerKeyFingerprint() {
+ return SERVER_KEY_FINGERPRINT;
+ }
+
+ static String toHex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte value : bytes) {
+ builder.append(String.format("%02x", value & 0xff));
+ }
+ return builder.toString();
+ }
+
+ static byte[] fromHex(String hex) {
+ String normalized = hex == null ? "" : hex.trim();
+ if ((normalized.length() % 2) != 0) throw new IllegalArgumentException("Invalid encrypted hex payload.");
+
+ byte[] out = new byte[normalized.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ int high = Character.digit(normalized.charAt(i * 2), 16);
+ int low = Character.digit(normalized.charAt((i * 2) + 1), 16);
+ if (high < 0 || low < 0) throw new IllegalArgumentException("Invalid encrypted hex payload.");
+ out[i] = (byte) ((high << 4) | low);
+ }
+ return out;
+ }
+
+ private record CacheEntry(long modified, byte[] bytes) {}
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java
index 9e2a4891..93763902 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java
@@ -44,7 +44,9 @@ public final class RememberJwtService {
}
private static int familyTtlDays() {
- return Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30));
+ int configured = Emulator.getConfig().getInt("login.remember.duration.days", 0);
+ if (configured <= 0) configured = Emulator.getConfig().getInt("login.remember.days", 30);
+ return Math.max(1, configured);
}
private static long familyTtlSeconds() {
diff --git a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java
index 5712a3b8..ee174242 100644
--- a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java
+++ b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java
@@ -13,6 +13,7 @@ import com.eu.habbo.habbohotel.games.tag.TagGame;
import com.eu.habbo.habbohotel.items.ItemManager;
import com.eu.habbo.habbohotel.items.interactions.InteractionPostIt;
import com.eu.habbo.habbohotel.items.interactions.InteractionRoller;
+import com.eu.habbo.habbohotel.items.interactions.wired.effects.WiredEffectSendSignal;
import com.eu.habbo.habbohotel.items.interactions.games.football.InteractionFootballGate;
import com.eu.habbo.habbohotel.messenger.Messenger;
import com.eu.habbo.habbohotel.modtool.WordFilter;
@@ -116,6 +117,7 @@ public class PluginManager {
RoomManager.HOME_ROOM_ID = Emulator.getConfig().getInt("hotel.home.room");
WiredManager.MAXIMUM_FURNI_SELECTION = Emulator.getConfig().getInt("hotel.wired.furni.selection.count");
WiredManager.TELEPORT_DELAY = Emulator.getConfig().getInt("wired.effect.teleport.delay", 500);
+ WiredEffectSendSignal.MAX_SIGNAL_DEPTH = Emulator.getConfig().getInt("wired.signal.max.depth", 100);
WiredEngine.MAX_RECURSION_DEPTH = Emulator.getConfig().getInt("wired.abuse.max.recursion.depth", 10);
WiredEngine.MAX_EVENTS_PER_WINDOW = Emulator.getConfig().getInt("wired.abuse.max.events.per.window", 100);
WiredEngine.RATE_LIMIT_WINDOW_MS = Emulator.getConfig().getInt("wired.abuse.rate.limit.window.ms", 10000);
diff --git a/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar
index c15dee6a..c60617de 100644
Binary files a/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar and b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar differ
diff --git a/Latest_Compiled_Version/config.ini.example b/Latest_Compiled_Version/config.ini.example
index 96571801..cd8bd750 100644
--- a/Latest_Compiled_Version/config.ini.example
+++ b/Latest_Compiled_Version/config.ini.example
@@ -10,6 +10,11 @@ db.pool.maxsize=100
# Encrypt your traffic
crypto.ws.enabled=0
+# Optional packet signing for encrypted WebSocket traffic.
+crypto.ws.signing.enabled=false
+# Optional persistent signing keys. Leave empty to auto-generate/persist them in emulator_settings.
+crypto.ws.signing.public_key=
+crypto.ws.signing.private_key=
#Game Configuration.
#Host IP. Most likely just 0.0.0.0 Use 127.0.0.1 if you want to play on LAN.
@@ -43,4 +48,24 @@ db.pool.leak_detection_ms = 20000 set to 0 to disable
enc.enabled=false
enc.e=3
enc.n=86851dd364d5c5cece3c883171cc6ddc5760779b992482bd1e20dd296888df91b33b936a7b93f06d29e8870f703a216257dec7c81de0058fea4cc5116f75e6efc4e9113513e45357dc3fd43d4efab5963ef178b78bd61e81a14c603b24c8bcce0a12230b320045498edc29282ff0603bc7b7dae8fc1b05b52b2f301a9dc783b7
-enc.d=59ae13e243392e89ded305764bdd9e92e4eafa67bb6dac7e1415e8c645b0950bccd26246fd0d4af37145af5fa026c0ec3a94853013eaae5ff1888360f4f9449ee023762ec195dff3f30ca0b08b8c947e3859877b5d7dced5c8715c58b53740b84e11fbc71349a27c31745fcefeeea57cff291099205e230e0c7c27e8e1c0512b
\ No newline at end of file
+enc.d=59ae13e243392e89ded305764bdd9e92e4eafa67bb6dac7e1415e8c645b0950bccd26246fd0d4af37145af5fa026c0ec3a94853013eaae5ff1888360f4f9449ee023762ec195dff3f30ca0b08b8c947e3859877b5d7dced5c8715c58b53740b84e11fbc71349a27c31745fcefeeea57cff291099205e230e0c7c27e8e1c0512b
+
+# Nitro secure runtime assets. JSON files are read live from disk.
+nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+# Secure runtime ECDH session TTL in seconds.
+nitro.secure.session_ttl_sec=900
+# Point this to your deployed Nitro `/configuration` folder when secure config assets are enabled.
+nitro.secure.config.root=
+nitro.secure.gamedata.root=
+# Set a persistent secret when using Cloudflare / multiple backend requests.
+nitro.secure.master_key=change-me-to-a-long-random-secret
+
+# Remember-me login tokens.
+login.remember.enabled=true
+login.remember.duration.days=30
+# Optional: set a persistent remember-me JWT secret here, otherwise one is generated and stored in emulator_settings.
+login.remember.jwt.secret=
+
+# Login news API.
+login.news.limit=5