Commit 9ce43602 authored by Benjamin Elsdon's avatar Benjamin Elsdon
Browse files

Multi-server dispatch support and server run modes

parent 83fe5818
package emu.grasscutter; package emu.grasscutter;
import java.util.ArrayList;
public final class Config { public final class Config {
public String DatabaseUrl = "mongodb://localhost:27017"; public String DatabaseUrl = "mongodb://localhost:27017";
...@@ -11,6 +13,7 @@ public final class Config { ...@@ -11,6 +13,7 @@ public final class Config {
public String DUMPS_FOLDER = "./dumps/"; public String DUMPS_FOLDER = "./dumps/";
public String KEY_FOLDER = "./keys/"; public String KEY_FOLDER = "./keys/";
public String RunMode = "HYBRID"; // HYBRID, DISPATCH_ONLY, GAME_ONLY
public GameServerOptions GameServer = new GameServerOptions(); public GameServerOptions GameServer = new GameServerOptions();
public DispatchServerOptions DispatchServer = new DispatchServerOptions(); public DispatchServerOptions DispatchServer = new DispatchServerOptions();
...@@ -30,6 +33,19 @@ public final class Config { ...@@ -30,6 +33,19 @@ public final class Config {
public Boolean UseSSL = true; public Boolean UseSSL = true;
public boolean AutomaticallyCreateAccounts = false; public boolean AutomaticallyCreateAccounts = false;
public RegionInfo[] GameServers = {};
public RegionInfo[] getGameServers() {
return GameServers;
}
public static class RegionInfo {
public String Name = "os_usa";
public String Title = "Test";
public String Ip = "127.0.0.1";
public int Port = 22102;
}
} }
public static class GameServerOptions { public static class GameServerOptions {
...@@ -38,6 +54,9 @@ public final class Config { ...@@ -38,6 +54,9 @@ public final class Config {
public String PublicIp = ""; public String PublicIp = "";
public int Port = 22102; public int Port = 22102;
public String DispatchServerDatabaseUrl = "mongodb://localhost:27017";
public String DispatchServerDatabaseCollection = "grasscutter";
public boolean LOG_PACKETS = false; public boolean LOG_PACKETS = false;
public int InventoryLimitWeapon = 2000; public int InventoryLimitWeapon = 2000;
......
...@@ -73,11 +73,26 @@ public final class Grasscutter { ...@@ -73,11 +73,26 @@ public final class Grasscutter {
DatabaseManager.initialize(); DatabaseManager.initialize();
// Start servers. // Start servers.
dispatchServer = new DispatchServer(); if(getConfig().RunMode.equalsIgnoreCase("HYBRID")) {
dispatchServer.start(); dispatchServer = new DispatchServer();
dispatchServer.start();
gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port));
gameServer.start(); gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port));
gameServer.start();
} else if(getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) {
dispatchServer = new DispatchServer();
dispatchServer.start();
} else if(getConfig().RunMode.equalsIgnoreCase("GAME_ONLY")) {
gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port));
gameServer.start();
} else {
getLogger().error("Invalid server run mode. " + getConfig().RunMode);
getLogger().error("Server run mode must be 'HYBRID', 'DISPATCH_ONLY', or 'GAME_ONLY'. Unable to start Grasscutter...");
getLogger().error("Shutting down...");
System.exit(1);
}
// Open console. // Open console.
startConsole(); startConsole();
...@@ -104,9 +119,14 @@ public final class Grasscutter { ...@@ -104,9 +119,14 @@ public final class Grasscutter {
try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) { try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
while ((input = br.readLine()) != null) { while ((input = br.readLine()) != null) {
try { try {
if(getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) {
getLogger().error("Commands are not supported in dispatch only mode");
return;
}
CommandMap.getInstance().invoke(null, input); CommandMap.getInstance().invoke(null, input);
} catch (Exception e) { } catch (Exception e) {
Grasscutter.getLogger().error("Command error: " + e.getMessage()); Grasscutter.getLogger().error("Command error: ");
e.printStackTrace();
} }
} }
} catch (Exception e) { } catch (Exception e) {
......
...@@ -74,36 +74,36 @@ public class DatabaseHelper { ...@@ -74,36 +74,36 @@ public class DatabaseHelper {
} }
public static void saveAccount(Account account) { public static void saveAccount(Account account) {
DatabaseManager.getDatastore().save(account); DatabaseManager.getAccountDatastore().save(account);
} }
public static Account getAccountByName(String username) { public static Account getAccountByName(String username) {
MorphiaCursor<Account> cursor = DatabaseManager.getDatastore().createQuery(Account.class).field("username").equalIgnoreCase(username).find(FIND_ONE); MorphiaCursor<Account> cursor = DatabaseManager.getAccountDatastore().createQuery(Account.class).field("username").equalIgnoreCase(username).find(FIND_ONE);
if (!cursor.hasNext()) return null; if (!cursor.hasNext()) return null;
return cursor.next(); return cursor.next();
} }
public static Account getAccountByToken(String token) { public static Account getAccountByToken(String token) {
if (token == null) return null; if (token == null) return null;
MorphiaCursor<Account> cursor = DatabaseManager.getDatastore().createQuery(Account.class).field("token").equal(token).find(FIND_ONE); MorphiaCursor<Account> cursor = DatabaseManager.getAccountDatastore().createQuery(Account.class).field("token").equal(token).find(FIND_ONE);
if (!cursor.hasNext()) return null; if (!cursor.hasNext()) return null;
return cursor.next(); return cursor.next();
} }
public static Account getAccountById(String uid) { public static Account getAccountById(String uid) {
MorphiaCursor<Account> cursor = DatabaseManager.getDatastore().createQuery(Account.class).field("_id").equal(uid).find(FIND_ONE); MorphiaCursor<Account> cursor = DatabaseManager.getAccountDatastore().createQuery(Account.class).field("_id").equal(uid).find(FIND_ONE);
if (!cursor.hasNext()) return null; if (!cursor.hasNext()) return null;
return cursor.next(); return cursor.next();
} }
public static Account getAccountByPlayerId(int playerId) { public static Account getAccountByPlayerId(int playerId) {
MorphiaCursor<Account> cursor = DatabaseManager.getDatastore().createQuery(Account.class).field("playerId").equal(playerId).find(FIND_ONE); MorphiaCursor<Account> cursor = DatabaseManager.getAccountDatastore().createQuery(Account.class).field("playerId").equal(playerId).find(FIND_ONE);
if (!cursor.hasNext()) return null; if (!cursor.hasNext()) return null;
return cursor.next(); return cursor.next();
} }
public static boolean deleteAccount(String username) { public static boolean deleteAccount(String username) {
Query<Account> q = DatabaseManager.getDatastore().createQuery(Account.class).field("username").equalIgnoreCase(username); Query<Account> q = DatabaseManager.getAccountDatastore().createQuery(Account.class).field("username").equalIgnoreCase(username);
return DatabaseManager.getDatastore().findAndDelete(q) != null; return DatabaseManager.getDatastore().findAndDelete(q) != null;
} }
......
...@@ -17,7 +17,10 @@ import emu.grasscutter.game.inventory.GenshinItem; ...@@ -17,7 +17,10 @@ import emu.grasscutter.game.inventory.GenshinItem;
public final class DatabaseManager { public final class DatabaseManager {
private static MongoClient mongoClient; private static MongoClient mongoClient;
private static MongoClient dispatchMongoClient;
private static Datastore datastore; private static Datastore datastore;
private static Datastore dispatchDatastore;
private static final Class<?>[] mappedClasses = new Class<?>[] { private static final Class<?>[] mappedClasses = new Class<?>[] {
DatabaseCounter.class, Account.class, GenshinPlayer.class, GenshinAvatar.class, GenshinItem.class, Friendship.class DatabaseCounter.class, Account.class, GenshinPlayer.class, GenshinAvatar.class, GenshinItem.class, Friendship.class
...@@ -26,18 +29,28 @@ public final class DatabaseManager { ...@@ -26,18 +29,28 @@ public final class DatabaseManager {
public static MongoClient getMongoClient() { public static MongoClient getMongoClient() {
return mongoClient; return mongoClient;
} }
public static Datastore getDatastore() { public static Datastore getDatastore() {
return datastore; return datastore;
} }
public static MongoDatabase getDatabase() { public static MongoDatabase getDatabase() {
return getDatastore().getDatabase(); return getDatastore().getDatabase();
} }
// Yes. I very dislike this method also but I'm lazy. Probably replace it by making the game server connect to the dispatch server instead.
public static Datastore getAccountDatastore() {
if(Grasscutter.getConfig().RunMode.equalsIgnoreCase("GAME_ONLY")) {
return dispatchDatastore;
} else {
return datastore;
}
}
public static void initialize() { public static void initialize() {
// Initialize // Initialize
mongoClient = new MongoClient(new MongoClientURI(Grasscutter.getConfig().DatabaseUrl)); mongoClient = new MongoClient(new MongoClientURI(Grasscutter.getConfig().DatabaseUrl));
dispatchMongoClient = new MongoClient(new MongoClientURI(Grasscutter.getConfig().getGameServerOptions().DispatchServerDatabaseUrl));
Morphia morphia = new Morphia(); Morphia morphia = new Morphia();
// TODO Update when migrating to Morphia 2.0 // TODO Update when migrating to Morphia 2.0
...@@ -50,6 +63,7 @@ public final class DatabaseManager { ...@@ -50,6 +63,7 @@ public final class DatabaseManager {
// Build datastore // Build datastore
datastore = morphia.createDatastore(mongoClient, Grasscutter.getConfig().DatabaseCollection); datastore = morphia.createDatastore(mongoClient, Grasscutter.getConfig().DatabaseCollection);
dispatchDatastore = morphia.createDatastore(dispatchMongoClient, Grasscutter.getConfig().getGameServerOptions().DispatchServerDatabaseCollection);
// Ensure indexes // Ensure indexes
try { try {
...@@ -67,6 +81,23 @@ public final class DatabaseManager { ...@@ -67,6 +81,23 @@ public final class DatabaseManager {
datastore.ensureIndexes(); datastore.ensureIndexes();
} }
} }
// Ensure indexes for dispatch server
try {
dispatchDatastore.ensureIndexes();
} catch (MongoCommandException e) {
Grasscutter.getLogger().info("Mongo index error: ", e);
// Duplicate index error
if (e.getCode() == 85) {
// Drop all indexes and re add them
MongoIterable<String> collections = dispatchDatastore.getDatabase().listCollectionNames();
for (String name : collections) {
dispatchDatastore.getDatabase().getCollection(name).dropIndexes();
}
// Add back indexes
dispatchDatastore.ensureIndexes();
}
}
} }
public static synchronized int getNextId(Class<?> c) { public static synchronized int getNextId(Class<?> c) {
......
...@@ -9,10 +9,7 @@ import java.net.InetSocketAddress; ...@@ -9,10 +9,7 @@ import java.net.InetSocketAddress;
import java.net.URI; import java.net.URI;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.security.KeyStore; import java.security.KeyStore;
import java.util.Base64; import java.util.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
...@@ -25,6 +22,7 @@ import com.sun.net.httpserver.HttpHandler; ...@@ -25,6 +22,7 @@ import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpsConfigurator; import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsServer; import com.sun.net.httpserver.HttpsServer;
import emu.grasscutter.Config;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import emu.grasscutter.database.DatabaseHelper; import emu.grasscutter.database.DatabaseHelper;
import emu.grasscutter.game.Account; import emu.grasscutter.game.Account;
...@@ -46,15 +44,18 @@ import com.sun.net.httpserver.HttpServer; ...@@ -46,15 +44,18 @@ import com.sun.net.httpserver.HttpServer;
public final class DispatchServer { public final class DispatchServer {
private final InetSocketAddress address; private final InetSocketAddress address;
private final Gson gson; private final Gson gson;
private QueryCurrRegionHttpRsp currRegion; //private QueryCurrRegionHttpRsp currRegion;
public String regionListBase64; public String regionListBase64;
public String regionCurrentBase64; public HashMap<String, RegionData> regions;
public HashMap<InetSocketAddress, String> usersIngame;
public static String query_region_list = ""; public static String query_region_list = "";
public static String query_cur_region = ""; public static String query_cur_region = "";
public DispatchServer() { public DispatchServer() {
this.regions = new HashMap<String, RegionData>();
this.usersIngame = new HashMap<InetSocketAddress, String>();
this.address = new InetSocketAddress(Grasscutter.getConfig().getDispatchOptions().Ip, Grasscutter.getConfig().getDispatchOptions().Port); this.address = new InetSocketAddress(Grasscutter.getConfig().getDispatchOptions().Ip, Grasscutter.getConfig().getDispatchOptions().Port);
this.gson = new GsonBuilder().create(); this.gson = new GsonBuilder().create();
...@@ -70,8 +71,13 @@ public final class DispatchServer { ...@@ -70,8 +71,13 @@ public final class DispatchServer {
return gson; return gson;
} }
public QueryCurrRegionHttpRsp getCurrRegion() { public QueryCurrRegionHttpRsp getCurrRegion(InetSocketAddress address) {
return currRegion; if(usersIngame.containsKey(address)) {
return regions.get(usersIngame.get(address)).parsedRegionQuery;
}
Grasscutter.getLogger().error("User is not logged in to dispatch server. " + address.getAddress() + ":" + address.getPort());
return null;
} }
public void loadQueries() { public void loadQueries() {
...@@ -100,39 +106,67 @@ public final class DispatchServer { ...@@ -100,39 +106,67 @@ public final class DispatchServer {
byte[] decoded2 = Base64.getDecoder().decode(query_cur_region); byte[] decoded2 = Base64.getDecoder().decode(query_cur_region);
QueryCurrRegionHttpRsp regionQuery = QueryCurrRegionHttpRsp.parseFrom(decoded2); QueryCurrRegionHttpRsp regionQuery = QueryCurrRegionHttpRsp.parseFrom(decoded2);
RegionSimpleInfo server = RegionSimpleInfo.newBuilder() List<RegionSimpleInfo> servers = new ArrayList<RegionSimpleInfo>();
.setName("os_usa") List<String> usedNames = new ArrayList<String>(); // List to check for potential naming conflicts
.setTitle(Grasscutter.getConfig().getGameServerOptions().Name) if(Grasscutter.getConfig().RunMode.equalsIgnoreCase("HYBRID")) { // Automatically add the game server if in hybrid mode
.setType("DEV_PUBLIC") String defaultServerName = "os_usa";
.setDispatchUrl("https://" + (Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) + ":" + getAddress().getPort() + "/query_cur_region") RegionSimpleInfo server = RegionSimpleInfo.newBuilder()
.build(); .setName("os_usa")
.setTitle(Grasscutter.getConfig().getGameServerOptions().Name)
RegionSimpleInfo serverTest2 = RegionSimpleInfo.newBuilder() .setType("DEV_PUBLIC")
.setName("os_euro") .setDispatchUrl("https://" + (Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) + ":" + getAddress().getPort() + "/query_cur_region_" + defaultServerName)
.setTitle("Grasscutter") .build();
.setType("DEV_PUBLIC") usedNames.add(defaultServerName);
.setDispatchUrl("https://" + (Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) + ":" + getAddress().getPort() + "/query_cur_region") servers.add(server);
.build();
RegionInfo serverRegion = regionQuery.getRegionInfo().toBuilder()
.setIp((Grasscutter.getConfig().getGameServerOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getGameServerOptions().Ip : Grasscutter.getConfig().getGameServerOptions().PublicIp))
.setPort(Grasscutter.getConfig().getGameServerOptions().Port)
.setSecretKey(ByteString.copyFrom(FileUtils.read(Grasscutter.getConfig().KEY_FOLDER + "dispatchSeed.bin")))
.build();
QueryCurrRegionHttpRsp parsedRegionQuery = regionQuery.toBuilder().setRegionInfo(serverRegion).build();
regions.put(defaultServerName, new RegionData(parsedRegionQuery, Base64.getEncoder().encodeToString(parsedRegionQuery.toByteString().toByteArray())));
} else {
if(Grasscutter.getConfig().getDispatchOptions().getGameServers().length == 0) {
Grasscutter.getLogger().error("Dispatch server has no game servers available. Exiting due to unplayable state.");
System.exit(1);
}
}
for (Config.DispatchServerOptions.RegionInfo regionInfo : Grasscutter.getConfig().getDispatchOptions().getGameServers()) {
if(usedNames.contains(regionInfo.Name)) {
Grasscutter.getLogger().error("Region name already in use.");
continue;
}
RegionSimpleInfo server = RegionSimpleInfo.newBuilder()
.setName(regionInfo.Name)
.setTitle(regionInfo.Title)
.setType("DEV_PUBLIC")
.setDispatchUrl("https://" + (Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) + ":" + getAddress().getPort() + "/query_cur_region_" + regionInfo.Name)
.build();
usedNames.add(regionInfo.Name);
servers.add(server);
RegionInfo serverRegion = regionQuery.getRegionInfo().toBuilder()
.setIp(regionInfo.Ip)
.setPort(regionInfo.Port)
.setSecretKey(ByteString.copyFrom(FileUtils.read(Grasscutter.getConfig().KEY_FOLDER + "dispatchSeed.bin")))
.build();
QueryCurrRegionHttpRsp parsedRegionQuery = regionQuery.toBuilder().setRegionInfo(serverRegion).build();
regions.put(regionInfo.Name, new RegionData(parsedRegionQuery, Base64.getEncoder().encodeToString(parsedRegionQuery.toByteString().toByteArray())));
}
QueryRegionListHttpRsp regionList = QueryRegionListHttpRsp.newBuilder() QueryRegionListHttpRsp regionList = QueryRegionListHttpRsp.newBuilder()
.addServers(server) .addAllServers(servers)
.addServers(serverTest2)
.setClientSecretKey(rl.getClientSecretKey()) .setClientSecretKey(rl.getClientSecretKey())
.setClientCustomConfigEncrypted(rl.getClientCustomConfigEncrypted()) .setClientCustomConfigEncrypted(rl.getClientCustomConfigEncrypted())
.setEnableLoginPc(true) .setEnableLoginPc(true)
.build(); .build();
RegionInfo currentRegion = regionQuery.getRegionInfo().toBuilder()
.setIp((Grasscutter.getConfig().getGameServerOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getGameServerOptions().Ip : Grasscutter.getConfig().getGameServerOptions().PublicIp))
.setPort(Grasscutter.getConfig().getGameServerOptions().Port)
.setSecretKey(ByteString.copyFrom(FileUtils.read(Grasscutter.getConfig().KEY_FOLDER + "dispatchSeed.bin")))
.build();
QueryCurrRegionHttpRsp parsedRegionQuery = regionQuery.toBuilder().setRegionInfo(currentRegion).build();
this.regionListBase64 = Base64.getEncoder().encodeToString(regionList.toByteString().toByteArray()); this.regionListBase64 = Base64.getEncoder().encodeToString(regionList.toByteString().toByteArray());
this.regionCurrentBase64 = Base64.getEncoder().encodeToString(parsedRegionQuery.toByteString().toByteArray());
this.currRegion = parsedRegionQuery;
} catch (Exception e) { } catch (Exception e) {
Grasscutter.getLogger().error("Error while initializing region info!", e); Grasscutter.getLogger().error("Error while initializing region info!", e);
} }
...@@ -188,24 +222,36 @@ public final class DispatchServer { ...@@ -188,24 +222,36 @@ public final class DispatchServer {
OutputStream os = t.getResponseBody(); OutputStream os = t.getResponseBody();
os.write(response.getBytes()); os.write(response.getBytes());
os.close(); os.close();
});
server.createContext("/query_cur_region", t -> { if(usersIngame.containsKey(t.getRemoteAddress())) {
// Log usersIngame.remove(t.getRemoteAddress());
Grasscutter.getLogger().info("Client request: query_cur_region");
// Create a response form the request query parameters
URI uri = t.getRequestURI();
String response = "CAESGE5vdCBGb3VuZCB2ZXJzaW9uIGNvbmZpZw==";
if (uri.getQuery() != null && uri.getQuery().length() > 0) {
response = regionCurrentBase64;
} }
// Set the response header status and length
t.getResponseHeaders().put("Content-Type", Collections.singletonList("text/html; charset=UTF-8"));
t.sendResponseHeaders(200, response.getBytes().length);
// Write the response string
OutputStream os = t.getResponseBody();
os.write(response.getBytes());
os.close();
}); });
for (String regionName : regions.keySet()) {
server.createContext("/query_cur_region_" + regionName, t -> {
String regionCurrentBase64 = regions.get(regionName).Base64;
// Log
Grasscutter.getLogger().info("Client request: query_cur_region_" + regionName);
// Create a response form the request query parameters
URI uri = t.getRequestURI();
String response = "CAESGE5vdCBGb3VuZCB2ZXJzaW9uIGNvbmZpZw==";
if (uri.getQuery() != null && uri.getQuery().length() > 0) {
response = regionCurrentBase64;
}
// Set the response header status and length
t.getResponseHeaders().put("Content-Type", Collections.singletonList("text/html; charset=UTF-8"));
t.sendResponseHeaders(200, response.getBytes().length);
// Write the response string
OutputStream os = t.getResponseBody();
os.write(response.getBytes());
os.close();
//Save region info to hashmap for user, this for getCurrRegion();
usersIngame.put(t.getRemoteAddress(), regionName);
});
}
// Login via account // Login via account
server.createContext("/hk4e_global/mdk/shield/api/login", t -> { server.createContext("/hk4e_global/mdk/shield/api/login", t -> {
// Get post data // Get post data
...@@ -464,4 +510,15 @@ public final class DispatchServer { ...@@ -464,4 +510,15 @@ public final class DispatchServer {
} }
return result; return result;
} }
public static class RegionData {
QueryCurrRegionHttpRsp parsedRegionQuery;
String Base64;
public RegionData(QueryCurrRegionHttpRsp prq, String b64) {
this.parsedRegionQuery = prq;
this.Base64 = b64;
}
}
} }
...@@ -14,7 +14,7 @@ public class PacketPlayerLoginRsp extends GenshinPacket { ...@@ -14,7 +14,7 @@ public class PacketPlayerLoginRsp extends GenshinPacket {
this.setUseDispatchKey(true); this.setUseDispatchKey(true);
RegionInfo info = Grasscutter.getDispatchServer().getCurrRegion().getRegionInfo(); RegionInfo info = Grasscutter.getDispatchServer().getCurrRegion(session.getAddress()).getRegionInfo();
PlayerLoginRsp p = PlayerLoginRsp.newBuilder() PlayerLoginRsp p = PlayerLoginRsp.newBuilder()
.setIsUseAbilityHash(true) // true .setIsUseAbilityHash(true) // true
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment