Merge branch 'server-api'

This commit is contained in:
Jonas_Jones 2023-09-05 21:30:27 +02:00
commit 6a6b9d41c2
14 changed files with 434 additions and 38 deletions

View file

@ -2,6 +2,7 @@ package me.jonasjones.mcwebserver;
import com.roxstudio.utils.CUrl;
import me.jonasjones.mcwebserver.config.ModConfigs;
import me.jonasjones.mcwebserver.web.api.v1.ApiHandler;
import me.jonasjones.mcwebserver.web.ServerHandler;
import net.fabricmc.api.ModInitializer;
import org.slf4j.Logger;
@ -9,7 +10,7 @@ import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
import static me.jonasjones.mcwebserver.config.ModConfigs.WEB_PORT;
import static me.jonasjones.mcwebserver.config.ModConfigs.*;
public class McWebserver implements ModInitializer {
// This logger is used to write text to the console and the log file.
@ -18,44 +19,26 @@ public class McWebserver implements ModInitializer {
public static String MOD_ID = "mcwebserver";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public static final Logger VERBOSELOGGER = LoggerFactory.getLogger(MOD_ID + " - VERBOSE LOGGER");
private static ServerHandler webserver = new ServerHandler();
public static Thread webserverthread = new Thread(webserver);
public static boolean mcserveractive = true;
@Override
public void onInitialize() {
// register configs
ModConfigs.registerConfigs();
LOGGER.info("McWebserver initialized!");
webserverthread.start();
new Thread(() -> {
while (true) {
if (!mcserveractive) {
sleep(2);
for (int i = 0; i < 2; i++) {
CUrl curl = new CUrl("http://localhost:" + WEB_PORT + "/index.html").timeout(1, 1);
curl.exec();
sleep(1);
}
LOGGER.info("Webserver Stopped!");
break;
} else {
sleep(2);
}
}
}).start();
}
private void sleep(int seconds) {
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (InterruptedException e) {
throw new RuntimeException(e);
if (SERVER_API_ENABLED) {
//start collecting api info
ApiHandler.startHandler();
LOGGER.info("Server API enabled!");
}
}
if (ADV_API_ENABLED) {
//start collecting advanced api info
ApiHandler.startAdvHandler();
LOGGER.info("Advanced Server API enabled!");
}
ServerHandler.start();
}
}

View file

@ -15,6 +15,8 @@ public class ModConfigs {
public static String WEB_ROOT;
public static String WEB_FILE_ROOT;
public static String WEB_FILE_404;
public static Boolean SERVER_API_ENABLED;
public static Boolean ADV_API_ENABLED;
public static String WEB_FILE_NOSUPPORT;
public static Boolean VERBOSE = false; //needs to be set to false since the verbose logger is called before config file is fully loaded
@ -40,6 +42,8 @@ public class ModConfigs {
config.addKeyValuePair(new Pair<>("web.root", "webserver/"), "the root directory of the webserver, starting from the main server directory");
config.addKeyValuePair(new Pair<>("web.file.root", "index.html"), "the name of the html file for the homepage");
config.addKeyValuePair(new Pair<>("web.file.404", "404.html"), "the name of the html file for 404 page");
config.addKeyValuePair(new Pair<>("web.api", true), "whether or not the webserver api should be enabled or not");
config.addKeyValuePair(new Pair<>("web.api.adv", true), "whether or not the api should expose information such as player coordinates and inventory");
config.addKeyValuePair(new Pair<>("web.file.notSupported", "not_supported.html"), "the name of the html file for 'not supported' page");
config.addKeyValuePair(new Pair<>("logger.verbose", true), "whether or not to log verbose output");
}
@ -50,6 +54,8 @@ public class ModConfigs {
WEB_ROOT = CONFIG.getOrDefault("web.root", "webserver/");
WEB_FILE_ROOT = CONFIG.getOrDefault("web.file.root", "index.html");
WEB_FILE_404 = CONFIG.getOrDefault("web.file.404", "404.html");
SERVER_API_ENABLED = CONFIG.getOrDefault("web.api", true);
ADV_API_ENABLED = CONFIG.getOrDefault("web.api.adv", true);
WEB_FILE_NOSUPPORT = CONFIG.getOrDefault("web.file.notSupported", "not_supported.html");
VERBOSE = CONFIG.getOrDefault("logger.verbose", true);
}

View file

@ -1,6 +1,7 @@
package me.jonasjones.mcwebserver.mixin;
import me.jonasjones.mcwebserver.McWebserver;
import me.jonasjones.mcwebserver.web.ServerHandler;
import net.minecraft.server.MinecraftServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
@ -12,6 +13,6 @@ public class WebserverStopMixin {
@Inject(at = @At("HEAD"), method = "shutdown")
private void init(CallbackInfo info) {
McWebserver.LOGGER.info("Stopping Webserver...");
McWebserver.mcserveractive = false;
ServerHandler.mcserveractive = false;
}
}

View file

@ -3,6 +3,9 @@ package me.jonasjones.mcwebserver.web;
import me.jonasjones.mcwebserver.config.ModConfigs;
import me.jonasjones.mcwebserver.McWebserver;
import me.jonasjones.mcwebserver.util.VerboseLogger;
import me.jonasjones.mcwebserver.web.api.v1.ApiHandler;
import me.jonasjones.mcwebserver.web.api.v1.ApiRequests;
import me.jonasjones.mcwebserver.web.api.v1.ApiRequestsUtil;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
@ -20,9 +23,10 @@ import java.nio.file.Path;
import java.time.Instant;
import java.util.StringTokenizer;
import static me.jonasjones.mcwebserver.McWebserver.mcserveractive;
import static me.jonasjones.mcwebserver.web.ServerHandler.mcserveractive;
import static me.jonasjones.mcwebserver.web.api.v1.ApiHandler.isApiRequest;
public class HTTPServer implements Runnable {
public class HttpServer implements Runnable {
static Path WEB_ROOT;
static final String DEFAULT_FILE = ModConfigs.WEB_FILE_ROOT;
static final String FILE_NOT_FOUND = ModConfigs.WEB_FILE_404;
@ -45,6 +49,7 @@ public class HTTPServer implements Runnable {
// Client Connection via Socket Class
private final Socket connect;
private final MimeTypeIdentifier mimetypeidentifier = new MimeTypeIdentifier();
private Boolean isApiRequest = false;
static {
try {
@ -54,7 +59,7 @@ public class HTTPServer implements Runnable {
}
}
public HTTPServer(Socket c) {
public HttpServer(Socket c) {
connect = c;
}
@ -66,12 +71,13 @@ public class HTTPServer implements Runnable {
// we listen until user halts server execution
while (mcserveractive) {
HTTPServer myServer = new HTTPServer(serverConnect.accept());
HttpServer myServer = new HttpServer(serverConnect.accept());
VerboseLogger.info("Connection opened. (" + Instant.now() + ")");
// create dedicated thread to manage the client connection
Thread thread = new Thread(myServer);
thread.setName("McWebserver-worker");
thread.start();
}
@ -109,6 +115,7 @@ public class HTTPServer implements Runnable {
// we support only GET and HEAD methods, we check
if (!method.equals("GET") && !method.equals("HEAD")) {
isApiRequest = false;
VerboseLogger.info("501 Not Implemented : " + method + " method.");
// we return the not supported file to the client
@ -128,8 +135,50 @@ public class HTTPServer implements Runnable {
// file
dataOut.write(fileData, 0, fileData.length);
dataOut.flush();
} else if (isApiRequest(fileRequested)) {
isApiRequest = true;
// Set appropriate response headers
dataOut.write("HTTP/1.1 200 OK\r\n".getBytes(StandardCharsets.UTF_8));
dataOut.write("Date: %s\r\n".formatted(Instant.now()).getBytes(StandardCharsets.UTF_8));
if (fileRequested.equals("/api/v1/servericon")) {
dataOut.write("Content-Type: image/png\r\n".getBytes(StandardCharsets.UTF_8));
// Get server icon from ApiHandler
byte[] serverIcon = ApiRequestsUtil.getServerIcon();
int contentLength = serverIcon.length;
dataOut.write(("Content-Length: " + contentLength + "\r\n").getBytes(StandardCharsets.UTF_8));
dataOut.write("\r\n".getBytes(StandardCharsets.UTF_8)); // Blank line before content
// Send server icon
dataOut.write(serverIcon, 0, contentLength);
dataOut.flush();
} else {
dataOut.write("Content-Type: application/json\r\n".getBytes(StandardCharsets.UTF_8));
String jsonString = "";
try {
// Get JSON data from ApiHandler
jsonString = ApiHandler.handle(fileRequested);
} catch (Exception e) {
VerboseLogger.error("Error getting JSON data from ApiHandler: " + e.getMessage());
jsonString = ApiRequests.internalServerError();
}
byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
int contentLength = jsonBytes.length;
dataOut.write(("Content-Length: " + contentLength + "\r\n").getBytes(StandardCharsets.UTF_8));
dataOut.write("\r\n".getBytes(StandardCharsets.UTF_8)); // Blank line before content
// Send JSON data
dataOut.write(jsonBytes, 0, contentLength);
dataOut.flush();
}
} else {
isApiRequest = false;
// GET or HEAD method
if (fileRequested.endsWith("/")) {
fileRequested += DEFAULT_FILE;
@ -172,6 +221,8 @@ public class HTTPServer implements Runnable {
} catch (NoSuchFileException e) {
try {
assert out != null;
assert dataOut != null;
fileNotFound(out, dataOut, fileRequested);
} catch (IOException ioe) {
VerboseLogger.error("Error with file not found exception : " + ioe.getMessage());
@ -183,6 +234,7 @@ public class HTTPServer implements Runnable {
try {
in.close();
out.close();
assert dataOut != null;
dataOut.close();
connect.close(); // we close socket connection
} catch (Exception e) {

View file

@ -1,21 +1,60 @@
package me.jonasjones.mcwebserver.web;
import com.roxstudio.utils.CUrl;
import me.jonasjones.mcwebserver.config.ModConfigs;
import java.net.Socket;
import java.util.concurrent.TimeUnit;
import static me.jonasjones.mcwebserver.McWebserver.LOGGER;
import static me.jonasjones.mcwebserver.config.ModConfigs.WEB_PORT;
public class ServerHandler implements Runnable {
public static Socket socket = new Socket();
private static final ServerHandler webserver = new ServerHandler();
public static Thread webserverthread = new Thread(webserver);
public static boolean mcserveractive = true;
public static void start() {
webserverthread.setName("McWebserver-webserver");
webserverthread.start();
Thread serverthread = new Thread(() -> {
while (true) {
if (!mcserveractive) {
sleep(2);
for (int i = 0; i < 2; i++) {
CUrl curl = new CUrl("http://localhost:" + WEB_PORT + "/api/v1/dummy").timeout(1, 1); // a truly awful way of stopping this thread
curl.exec();
sleep(1);
}
LOGGER.info("Webserver Stopped!");
break;
} else {
sleep(2);
}
}
});
serverthread.setName("McWebserver-main");
serverthread.start();
}
private static void sleep(int seconds) {
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public void run() {
if (ModConfigs.IS_ENABLED) {
LOGGER.info("Starting Webserver...");
new HTTPServer(socket);
HTTPServer.main();
new HttpServer(socket);
HttpServer.main();
} else {
LOGGER.info("Webserver disabled in the config file.");
}

View file

@ -0,0 +1,100 @@
package me.jonasjones.mcwebserver.web.api.v1;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
public class ApiHandler {
public static Boolean isApiRequest(String request) {
return request.startsWith("/api/v1/");
}
public static String handle(String request) {
switch (request.replace("/api/v1/", "")) {
// Simple API Requests
case "motd" -> {
return ApiRequests.singleValueRequest(ApiRequestsUtil.getMOTD());
}
case "serverip" -> {
return ApiRequests.singleValueRequest(ApiRequestsUtil.getSERVER_IP());
}
case "serverport" -> {
return ApiRequests.singleValueRequest(String.valueOf(ApiRequestsUtil.getSERVER_PORT()));
}
case "servername" -> {
return ApiRequests.singleValueRequest(ApiRequestsUtil.getSERVER_NAME());
}
case "serverversion" -> {
return ApiRequests.singleValueRequest(ApiRequestsUtil.getSERVER_VERSION());
}
case "loaderversion" -> {
return ApiRequests.singleValueRequest(ApiRequestsUtil.getLOADER_VERSION());
}
case "currentplayercount" -> {
return ApiRequests.singleValueRequest(String.valueOf(ApiRequestsUtil.getCURRENT_PLAYER_COUNT()));
}
case "defaultgamemode" -> {
return ApiRequests.singleValueRequest(ApiRequestsUtil.getDEFAULT_GAME_MODE().toString());
}
case "maxplayercount" -> {
return ApiRequests.singleValueRequest(String.valueOf(ApiRequestsUtil.getMAX_PLAYER_COUNT()));
}
case "playernames" -> {
return ApiRequests.playerNamesRequest();
}
case "servermetadata" -> {
return ApiRequests.serverMetadataRequest();
}
case "ticks" -> {
return ApiRequests.singleValueRequest(String.valueOf(ApiRequestsUtil.getTICKS()));
}
case "ticktime" -> {
return ApiRequests.singleValueRequest(String.valueOf(ApiRequestsUtil.getTICK_TIME()));
}
case "timereference" -> {
return ApiRequests.singleValueRequest(String.valueOf(ApiRequestsUtil.getTIME_REFERENCE()));
}
case "getall" -> {
return ApiRequests.serverGetAllRequest();
}
default -> {
return ApiRequests.badRequest();
}
}
}
public static void startHandler() {
//This is a really awful way of collection all this info. Please don't kill me.
ServerTickEvents.END_SERVER_TICK.register(server -> {
if (server.isRunning()) {
ApiRequestsUtil.setMOTD(server.getServerMotd());
ApiRequestsUtil.setSERVER_IP(server.getServerIp());
ApiRequestsUtil.setSERVER_PORT(server.getServerPort());
ApiRequestsUtil.setSERVER_NAME(server.getName());
ApiRequestsUtil.setSERVER_VERSION(server.getVersion());
ApiRequestsUtil.setCURRENT_PLAYER_COUNT(server.getCurrentPlayerCount());
ApiRequestsUtil.setDEFAULT_GAME_MODE(server.getDefaultGameMode());
ApiRequestsUtil.setMAX_PLAYER_COUNT(server.getMaxPlayerCount());
ApiRequestsUtil.setSERVER_METADATA(server.getServerMetadata());
ApiRequestsUtil.setTICKS(server.getTicks());
ApiRequestsUtil.setTICK_TIME(server.getTickTime());
ApiRequestsUtil.setTIME_REFERENCE(server.getTimeReference());
}
});
}
public static void startAdvHandler() {
//This is a really awful way of collection all this info. Please don't kill me.
ServerTickEvents.END_SERVER_TICK.register(server -> {
if (server.isRunning()) {
ApiRequestsUtil.setSERVER_PLAYER_ENTITY_LIST(server.getPlayerManager().getPlayerList());
ApiRequestsUtil.setSERVER_RESOURCE_PACK_PROFILE_COLLECTION(server.getDataPackManager().getProfiles());
ApiRequestsUtil.setSERVER_ADVANCEMENT_COLLECTION(server.getAdvancementLoader().getAdvancements());
ApiRequestsUtil.setSERVER_BOSSBAR_COLLECTION(server.getBossBarManager().getAll());
ApiRequestsUtil.getSERVER_PLAYER_ENTITY_LIST().forEach(serverPlayerEntity -> {
});
//SERVER_PLAYER_ENTITY_LIST = server.getPlayerInteractionManager().getPlayerList();
}
});
}
}

View file

@ -0,0 +1,31 @@
package me.jonasjones.mcwebserver.web.api.v1;
import com.google.gson.Gson;
public class ApiRequests {
private static final Gson gson = new Gson();
public static String singleValueRequest(String value) {
return "[\"" + value + "\"]";
}
public static String playerNamesRequest() {
return gson.toJsonTree(ApiRequestsUtil.convertPlayerList(ApiRequestsUtil.getSERVER_METADATA().players().get().sample())).getAsJsonArray().toString();
}
public static String serverMetadataRequest() {
return gson.toJson(ApiRequestsUtil.serverMetadata());
}
public static String serverGetAllRequest() {
return gson.toJson(ApiRequestsUtil.getAll());
}
public static String badRequest() {
return "{\"error\":{\"status\":400,\"message\":\"Bad Request\"}}";
}
public static String internalServerError() {
return "{\"error\":{\"status\":500,\"message\":\"Internal Server Error\"}}";
}
}

View file

@ -0,0 +1,122 @@
package me.jonasjones.mcwebserver.web.api.v1;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.authlib.GameProfile;
import lombok.Getter;
import lombok.Setter;
import me.jonasjones.mcwebserver.web.api.v1.json.ApiServerInfo;
import me.jonasjones.mcwebserver.web.api.v1.json.ApiServerMetadata;
import me.jonasjones.mcwebserver.web.api.v1.json.ApiServerMetadataPlayer;
import me.jonasjones.mcwebserver.web.api.v1.json.ApiServerMetadataPlayers;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.advancement.Advancement;
import net.minecraft.entity.boss.CommandBossBar;
import net.minecraft.resource.ResourcePackProfile;
import net.minecraft.server.ServerMetadata;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.world.GameMode;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static me.jonasjones.mcwebserver.config.ModConfigs.WEB_PORT;
public class ApiRequestsUtil {
@Getter @Setter
private static String MOTD;
@Getter @Setter
private static String SERVER_IP;
@Getter @Setter
private static int SERVER_PORT;
@Getter @Setter
private static String SERVER_NAME;
@Getter @Setter
private static String SERVER_VERSION;
@Getter @Setter
private static int CURRENT_PLAYER_COUNT;
@Getter @Setter
private static GameMode DEFAULT_GAME_MODE;
@Getter
private static final String LOADER_VERSION = FabricLoader.getInstance().getModContainer("fabricloader").get().getMetadata().getVersion().getFriendlyString();
@Getter @Setter
private static int MAX_PLAYER_COUNT;
@Getter @Setter
private static ServerMetadata SERVER_METADATA;
@Getter @Setter
private static int TICKS;
@Getter @Setter
private static float TICK_TIME;
@Getter @Setter
private static long TIME_REFERENCE;
@Getter @Setter
private static List<ServerPlayerEntity> SERVER_PLAYER_ENTITY_LIST = new ArrayList<>();
@Getter @Setter
private static Collection<ResourcePackProfile> SERVER_RESOURCE_PACK_PROFILE_COLLECTION = new ArrayList<>();
@Getter @Setter
private static Collection<Advancement> SERVER_ADVANCEMENT_COLLECTION = new ArrayList<>();
@Getter @Setter
private static Collection<CommandBossBar> SERVER_BOSSBAR_COLLECTION = new ArrayList<>();
private static final ApiServerInfo apiServerInfo = new ApiServerInfo();
private static final ApiServerMetadata apiServerMetadata = new ApiServerMetadata();
private static final ApiServerMetadataPlayers apiServerMetadataPlayers = new ApiServerMetadataPlayers();
private static final Gson gson = new Gson();
public static JsonObject serverMetadata() {
apiServerMetadataPlayers.setMAX(ApiRequestsUtil.getSERVER_METADATA().players().get().max());
apiServerMetadataPlayers.setONLINE(ApiRequestsUtil.getSERVER_METADATA().players().get().online());
apiServerMetadataPlayers.setSAMPLE(convertPlayerList(ApiRequestsUtil.getSERVER_METADATA().players().get().sample()));
apiServerMetadata.setDESCRIPTION(ApiRequestsUtil.getSERVER_METADATA().description().getString());
apiServerMetadata.setPLAYERS(JsonParser.parseString(gson.toJson(apiServerMetadataPlayers)).getAsJsonObject());
apiServerMetadata.setVERSION((JsonObject) JsonParser.parseString("{\"version\":\"" + ApiRequestsUtil.getSERVER_METADATA().version().get().gameVersion() + "\",\"protocol\":" + ApiRequestsUtil.getSERVER_METADATA().version().get().protocolVersion() + "}"));
if (ApiRequestsUtil.getSERVER_METADATA().favicon().isPresent()) {
if (!ApiRequestsUtil.getSERVER_IP().equals("")) {
apiServerMetadata.setFAVICON("http://" + ApiRequestsUtil.getSERVER_IP() + ":" + WEB_PORT + "/api/v1/servericon");
} else {
apiServerMetadata.setFAVICON("/api/v1/servericon");
}
} else {
apiServerMetadata.setFAVICON(""); // if favicon doesn't exist
}
apiServerMetadata.setSECURE_CHAT_EINFORCED(ApiRequestsUtil.getSERVER_METADATA().secureChatEnforced());
return JsonParser.parseString(gson.toJson(apiServerMetadata)).getAsJsonObject();
}
public static ArrayList<ApiServerMetadataPlayer> convertPlayerList(List<GameProfile> list) {
ArrayList<ApiServerMetadataPlayer> players = new ArrayList<>();
for (GameProfile profile : list) {
ApiServerMetadataPlayer player = new ApiServerMetadataPlayer();
player.setID(profile.getId().toString());
player.setNAME(profile.getName());
//player.setPROPERTIES(profile.getProperties().toString()); //Add support for the properties later
player.setLEGACY(profile.isLegacy());
players.add(player);
}
return players;
}
public static JsonObject getAll() {
apiServerInfo.setSERVER_IP(ApiRequestsUtil.getSERVER_IP());
apiServerInfo.setSERVER_PORT(ApiRequestsUtil.getSERVER_PORT());
apiServerInfo.setSERVER_NAME(ApiRequestsUtil.getSERVER_NAME());
apiServerInfo.setDEFAULT_GAME_MODE(ApiRequestsUtil.getDEFAULT_GAME_MODE().toString());
apiServerInfo.setLOADER_VERSION(LOADER_VERSION);
apiServerInfo.setMETADATA(serverMetadata());
apiServerInfo.setTICKS(ApiRequestsUtil.getTICKS());
apiServerInfo.setTICK_TIME(ApiRequestsUtil.getTICK_TIME());
apiServerInfo.setTIME_REFERENCE(ApiRequestsUtil.getTIME_REFERENCE());
return gson.toJsonTree(apiServerInfo).getAsJsonObject();
}
public static byte[] getServerIcon() {
return ApiRequestsUtil.getSERVER_METADATA().favicon().get().iconBytes();
}
}

View file

@ -0,0 +1,17 @@
package me.jonasjones.mcwebserver.web.api.v1.json;
import com.google.gson.JsonObject;
import lombok.Setter;
@Setter
public class ApiServerInfo {
private String SERVER_IP;
private int SERVER_PORT;
private String SERVER_NAME;
private String DEFAULT_GAME_MODE;
private String LOADER_VERSION;
private JsonObject METADATA;
private int TICKS;
private float TICK_TIME;
private long TIME_REFERENCE;
}

View file

@ -0,0 +1,14 @@
package me.jonasjones.mcwebserver.web.api.v1.json;
import com.google.gson.JsonObject;
import lombok.Setter;
@Setter
public class ApiServerMetadata {
private String DESCRIPTION;
private JsonObject PLAYERS;
private JsonObject VERSION;
private String FAVICON;
private Boolean SECURE_CHAT_EINFORCED;
}

View file

@ -0,0 +1,11 @@
package me.jonasjones.mcwebserver.web.api.v1.json;
import lombok.Setter;
@Setter
public class ApiServerMetadataPlayer {
private String ID;
private String NAME;
private String PROPERTIES;
private Boolean LEGACY;
}

View file

@ -0,0 +1,4 @@
package me.jonasjones.mcwebserver.web.api.v1.json;
public class ApiServerMetadataPlayerProperty {
}

View file

@ -0,0 +1,12 @@
package me.jonasjones.mcwebserver.web.api.v1.json;
import lombok.Setter;
import java.util.ArrayList;
@Setter
public class ApiServerMetadataPlayers {
private int MAX;
private int ONLINE;
private ArrayList<ApiServerMetadataPlayer> SAMPLE;
}