diff --git a/src/main/java/org/allaymc/netallay/NetAllay.java b/src/main/java/org/allaymc/netallay/NetAllay.java
index 66f5675..2e2e643 100644
--- a/src/main/java/org/allaymc/netallay/NetAllay.java
+++ b/src/main/java/org/allaymc/netallay/NetAllay.java
@@ -86,6 +86,9 @@ public void onEnable() {
config = NetAllayConfig.load(pluginContainer.dataFolder());
pluginLogger.info("Configuration loaded.");
+ // Start HTTP client for order API
+ org.allaymc.netallay.shop.WebUtil.startHttpClient();
+
// Initialize shop manager
shopManager = new ShopManager(this);
@@ -110,6 +113,9 @@ public void onDisable() {
shopManager = null;
}
+ // Stop HTTP client
+ org.allaymc.netallay.shop.WebUtil.stopHttpClient();
+
pluginLogger.info("NetAllay disabled.");
instance = null;
}
@@ -132,6 +138,20 @@ public void listenForEvent(String namespace, String systemName, String eventName
pluginLogger.debug("Registered listener for {}:{}:{}", namespace, systemName, eventName);
}
+ /**
+ * Registers a listener for client engine callbacks.
+ *
+ * Engine callbacks use a different format: [eventName, [], null]
+ * Common engine callbacks: StoreBuySuccServerEvent, UrgeShipEvent
+ *
+ * @param eventName the engine callback name
+ * @param handler the callback function to handle the event
+ */
+ public void listenForClientEngineCall(String eventName, PyRpcHandler handler) {
+ listenerRegistry.register("engine", "callback", eventName, handler);
+ pluginLogger.debug("Registered engine callback listener for: {}", eventName);
+ }
+
/**
* Unregisters a specific listener for an event.
*
diff --git a/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java b/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java
index cfa3c5c..3df7cf6 100644
--- a/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java
+++ b/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java
@@ -6,6 +6,9 @@
import org.msgpack.value.Value;
import org.msgpack.value.ValueType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
@@ -24,6 +27,8 @@
*/
public final class PyRpcCodec {
+ private static final Logger log = LoggerFactory.getLogger(PyRpcCodec.class);
+
/**
* Server to Client event type identifier.
*/
@@ -88,50 +93,76 @@ public static ParsedEvent decode(byte[] data) {
Value value = unpacker.unpackValue();
if (!value.isArrayValue()) {
+ log.warn("[Decode] Root is not array: {}", value.getValueType());
return null;
}
List array = value.asArrayValue().list();
if (array.size() < 2) {
+ log.warn("[Decode] Root array too short: size={}", array.size());
return null;
}
// Parse event type
String eventType = valueToString(array.get(0));
- // Parse event details
+ // Parse event details - can be array or map depending on packet type
Value detailsValue = array.get(1);
- if (!detailsValue.isArrayValue()) {
- return null;
- }
- List details = detailsValue.asArrayValue().list();
- if (details.size() < 4) {
- return null;
- }
+ // Standard format: [eventType, [namespace, system, event, data], ...]
+ if (detailsValue.isArrayValue()) {
+ List details = detailsValue.asArrayValue().list();
+ if (details.isEmpty()) {
+ // Engine callback format: [eventType, [], null]
+ // eventType IS the event name (e.g. StoreBuySuccServerEvent, UrgeShipEvent)
+ return new ParsedEvent(eventType, "engine", "callback", eventType, new HashMap<>());
+ }
- String namespace = valueToString(details.get(0));
- String systemName = valueToString(details.get(1));
- String eventName = valueToString(details.get(2));
- Object eventData = valueToObject(details.get(3));
-
- Map dataMap;
- if (eventData instanceof Map) {
- @SuppressWarnings("unchecked")
- Map temp = (Map) eventData;
- dataMap = temp;
- } else {
- dataMap = new HashMap<>();
- if (eventData != null) {
- dataMap.put("rawData", eventData);
+ if (details.size() < 3) {
+ log.warn("[Decode] Details array too short: size={}, eventType={}", details.size(), eventType);
+ return null;
}
+
+ String namespace = valueToString(details.get(0));
+ String systemName = valueToString(details.get(1));
+ String eventName = valueToString(details.get(2));
+ Object eventData = details.size() > 3 ? valueToObject(details.get(3)) : null;
+
+ Map dataMap = toDataMap(eventData);
+ return new ParsedEvent(eventType, namespace, systemName, eventName, dataMap);
}
- return new ParsedEvent(eventType, namespace, systemName, eventName, dataMap);
+ // Alternative format: [eventType, {data}, ...] (engine callbacks etc.)
+ if (detailsValue.isMapValue() || detailsValue.isBinaryValue() || detailsValue.isStringValue()) {
+ Object eventData = valueToObject(detailsValue);
+ Map dataMap = toDataMap(eventData);
+ // Use eventType as both namespace and event name for non-standard formats
+ return new ParsedEvent(eventType, "engine", "callback", eventType, dataMap);
+ }
- } catch (IOException e) {
+ log.warn("[Decode] Unexpected details type: {}, eventType={}", detailsValue.getValueType(), eventType);
return null;
+
+ } catch (Exception e) {
+ log.warn("[Decode] Failed to decode PyRpc packet: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Converts event data to a Map.
+ */
+ private static Map toDataMap(Object eventData) {
+ if (eventData instanceof Map) {
+ @SuppressWarnings("unchecked")
+ Map temp = (Map) eventData;
+ return temp;
+ }
+ Map dataMap = new HashMap<>();
+ if (eventData != null) {
+ dataMap.put("rawData", eventData);
}
+ return dataMap;
}
/**
diff --git a/src/main/java/org/allaymc/netallay/shop/ShopManager.java b/src/main/java/org/allaymc/netallay/shop/ShopManager.java
index f48f15a..8b7f4f6 100644
--- a/src/main/java/org/allaymc/netallay/shop/ShopManager.java
+++ b/src/main/java/org/allaymc/netallay/shop/ShopManager.java
@@ -7,8 +7,13 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
import java.util.*;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiConsumer;
/**
* Manager for NetEase shop functionality.
@@ -242,6 +247,92 @@ public boolean showHint(Player player, String head, String tail) {
);
}
+ // ==================== Order API ====================
+
+ /**
+ * Gets the active signing key based on test/production mode.
+ */
+ private String getActiveSignKey() {
+ return testServer ? testGameKey : gameKey;
+ }
+
+ /**
+ * Gets the shop server base URL.
+ */
+ private String getShopBaseUrl() {
+ return WebUtil.getShopBaseUrl(shopServerUrl, testServer);
+ }
+
+ /**
+ * Retrieves the player's order list from the NetEase order server.
+ *
+ * This is an async operation. The callback receives the JSON response on success,
+ * or an exception on failure.
+ *
+ * Usage Example:
+ *
{@code
+ * shop.getPlayerOrderList(player, (result, error) -> {
+ * if (error != null) {
+ * log.error("Failed to get orders", error);
+ * return;
+ * }
+ * JsonArray entities = result.getAsJsonArray("entities");
+ * // Process each order...
+ * });
+ * }
+ *
+ * @param player the player
+ * @param callback callback with (JsonObject result, Throwable error)
+ */
+ public void getPlayerOrderList(Player player, BiConsumer callback) {
+ String uuid = player.getControlledEntity().getUniqueId().toString();
+
+ WebUtil.getPlayerOrderList(gameId, uuid, getActiveSignKey(), getShopBaseUrl())
+ .whenComplete((result, error) -> {
+ if (error != null) {
+ log.error("Failed to get order list for {}", player.getOriginName(), error);
+ } else {
+ log.debug("Got order list for {}: {}", player.getOriginName(), result);
+ }
+ callback.accept(result, error);
+ });
+ }
+
+ /**
+ * Notifies the NetEase order server that orders have been shipped/completed.
+ *
+ * Call this after you have delivered the items to the player.
+ *
+ * Usage Example:
+ *
{@code
+ * List finishedOrderIds = List.of("order123", "order456");
+ * shop.finishPlayerOrder(player, finishedOrderIds, (result, error) -> {
+ * if (error != null) {
+ * log.error("Failed to finish orders", error);
+ * return;
+ * }
+ * log.info("Orders completed successfully");
+ * });
+ * }
+ *
+ * @param player the player
+ * @param orderIds list of order IDs that have been delivered
+ * @param callback callback with (JsonObject result, Throwable error)
+ */
+ public void finishPlayerOrder(Player player, List orderIds, BiConsumer callback) {
+ String uuid = player.getControlledEntity().getUniqueId().toString();
+
+ WebUtil.finishPlayerOrder(gameId, uuid, orderIds, getActiveSignKey(), getShopBaseUrl())
+ .whenComplete((result, error) -> {
+ if (error != null) {
+ log.error("Failed to finish orders for {}", player.getOriginName(), error);
+ } else {
+ log.info("Orders completed for {}: {}", player.getOriginName(), orderIds);
+ }
+ callback.accept(result, error);
+ });
+ }
+
// ==================== Per-Player Configuration ====================
/**
@@ -304,7 +395,7 @@ private void registerInternalListeners() {
(player, data) -> fireShopEvent(ShopEvent.PLAYER_URGE_SHIP, player, data)
);
- // Listen for client buy success
+ // Listen for client buy success (mod event)
plugin.listenForEvent(
ShopConstants.SHOP_NAMESPACE,
ShopConstants.SHOP_CLIENT_SYSTEM,
@@ -312,6 +403,16 @@ private void registerInternalListeners() {
(player, data) -> fireShopEvent(ShopEvent.PLAYER_BUY_ITEM_SUCCESS, player, data)
);
+ // Listen for engine callbacks (alternative event format from client)
+ plugin.listenForClientEngineCall(
+ "StoreBuySuccServerEvent",
+ (player, data) -> fireShopEvent(ShopEvent.PLAYER_BUY_ITEM_SUCCESS, player, data)
+ );
+ plugin.listenForClientEngineCall(
+ "UrgeShipEvent",
+ (player, data) -> fireShopEvent(ShopEvent.PLAYER_URGE_SHIP, player, data)
+ );
+
log.debug("Shop internal listeners registered");
}
@@ -342,7 +443,16 @@ private void handleClientEnter(Player player, Map data) {
responseData.put("isTestServer", testServer);
responseData.put("useCustomShop", useCustomShop);
responseData.put("cacheTime", cacheTime);
- responseData.put("uid", 0L);
+
+ // Get NetEase UID from player's login data
+ long uid = 0L;
+ if (player.isNetEasePlayer()) {
+ var neteaseData = player.getLoginData().getNetEaseData();
+ if (neteaseData != null) {
+ uid = neteaseData.uid();
+ }
+ }
+ responseData.put("uid", uid);
responseData.put("platformUid", "");
}
diff --git a/src/main/java/org/allaymc/netallay/shop/WebUtil.java b/src/main/java/org/allaymc/netallay/shop/WebUtil.java
new file mode 100644
index 0000000..fb06ee3
--- /dev/null
+++ b/src/main/java/org/allaymc/netallay/shop/WebUtil.java
@@ -0,0 +1,176 @@
+package org.allaymc.netallay.shop;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Utility class for communicating with NetEase shop order servers.
+ *
+ * Handles HMAC-SHA256 signed HTTP requests for order retrieval and completion.
+ *
+ * @author YiRanKuma
+ */
+public class WebUtil {
+
+ private static final Logger log = LoggerFactory.getLogger(WebUtil.class);
+ private static final Gson GSON = new Gson();
+
+ public static final String GAS_SERVER_BASE_URL = "http://gasproxy.mc.netease.com:60002";
+ public static final String TEST_GAS_SERVER_BASE_URL = "http://gasproxy.mc.netease.com:60001";
+
+ public static final String GET_ITEM_ORDER_LIST_PATH = "/get-mc-item-order-list";
+ public static final String SHIP_ITEM_ORDER_PATH = "/ship-mc-item-order";
+
+ private static HttpClient httpClient;
+
+ /**
+ * Starts the HTTP client.
+ */
+ public static void startHttpClient() {
+ httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+ log.info("HTTP client started");
+ }
+
+ /**
+ * Stops the HTTP client.
+ */
+ public static void stopHttpClient() {
+ httpClient = null;
+ log.info("HTTP client stopped");
+ }
+
+ /**
+ * Selects the appropriate base URL for shop order requests.
+ */
+ public static String getShopBaseUrl(String customUrl, boolean isTestServer) {
+ if (customUrl != null && !customUrl.isEmpty()) {
+ return customUrl;
+ }
+ return isTestServer ? TEST_GAS_SERVER_BASE_URL : GAS_SERVER_BASE_URL;
+ }
+
+ /**
+ * Gets the player's order list from NetEase order server.
+ *
+ * @param gameId the game ID
+ * @param playerUuid the player's UUID string
+ * @param signKey the game key for signing (testGameKey or gameKey)
+ * @param shopBaseUrl the base URL for the order server
+ * @return CompletableFuture with the parsed JSON response
+ */
+ public static CompletableFuture getPlayerOrderList(
+ String gameId, String playerUuid, String signKey, String shopBaseUrl) {
+
+ JsonObject payload = new JsonObject();
+ payload.addProperty("gameid", gameId);
+ payload.addProperty("uuid", playerUuid);
+ String json = GSON.toJson(payload);
+
+ return postSigned(shopBaseUrl + GET_ITEM_ORDER_LIST_PATH, GET_ITEM_ORDER_LIST_PATH, json, signKey);
+ }
+
+ /**
+ * Notifies the NetEase order server that orders have been shipped.
+ *
+ * @param gameId the game ID
+ * @param playerUuid the player's UUID string
+ * @param orderIds list of completed order IDs
+ * @param signKey the game key for signing
+ * @param shopBaseUrl the base URL for the order server
+ * @return CompletableFuture with the parsed JSON response
+ */
+ public static CompletableFuture finishPlayerOrder(
+ String gameId, String playerUuid, List orderIds, String signKey, String shopBaseUrl) {
+
+ JsonObject payload = new JsonObject();
+ payload.addProperty("gameid", gameId);
+ payload.addProperty("uuid", playerUuid);
+ payload.add("orderid_list", GSON.toJsonTree(orderIds));
+ String json = GSON.toJson(payload);
+
+ return postSigned(shopBaseUrl + SHIP_ITEM_ORDER_PATH, SHIP_ITEM_ORDER_PATH, json, signKey);
+ }
+
+ /**
+ * Sends a signed POST request to the NetEase server.
+ */
+ private static CompletableFuture postSigned(String url, String path, String jsonBody, String signKey) {
+ if (httpClient == null) {
+ return CompletableFuture.failedFuture(new IllegalStateException("HTTP client not started"));
+ }
+
+ String signature;
+ try {
+ signature = getServerSign(signKey, "POST", path, jsonBody);
+ } catch (Exception e) {
+ return CompletableFuture.failedFuture(e);
+ }
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(15))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .header("Netease-Server-Sign", signature)
+ .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
+ .build();
+
+ log.debug("Sending POST to {}", url);
+
+ return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
+ .thenApply(response -> {
+ if (response.statusCode() != 200) {
+ throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body());
+ }
+ JsonObject jsonResult = JsonParser.parseString(response.body()).getAsJsonObject();
+ int code = jsonResult.has("code") ? jsonResult.get("code").getAsInt() : -1;
+ if (code != 0) {
+ throw new RuntimeException("API error code: " + code + ", response: " + response.body());
+ }
+ return jsonResult;
+ });
+ }
+
+ /**
+ * Generates HMAC-SHA256 signature for NetEase API requests.
+ *
+ * Sign string format: METHOD + PATH + BODY
+ */
+ public static String getServerSign(String signKey, String method, String path, String httpBody) throws Exception {
+ String str2sign = method + path + httpBody;
+ String signStr = hmacSHA256(str2sign, signKey);
+ // Pad to 64 characters with leading zeros if needed
+ if (signStr.length() < 64) {
+ int padLen = 64 - signStr.length();
+ signStr = String.join("", Collections.nCopies(padLen, "0")) + signStr;
+ }
+ return signStr;
+ }
+
+ private static String hmacSHA256(String data, String key) throws Exception {
+ SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(secretKey);
+ mac.update(data.getBytes(StandardCharsets.UTF_8));
+ byte[] result = mac.doFinal();
+ return new BigInteger(1, result).toString(16);
+ }
+}