diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..35d6238b2e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DB_HOST=db +DB_PORT=3306 +DB_NAME=cosmic +DB_USER=root +DB_PASS= diff --git a/.gitignore b/.gitignore index 27980c65f98..45251782214 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !.idea/codeStyles/ *.iml /target +.env # build files /build/ diff --git a/README.md b/README.md index 366ab226247..ca2be7de9d6 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,71 @@ The project follows the [semantic versioning](https://semver.org/) scheme using * *General changes or improvements* are treated as MINOR: 1.__2__.3 -> 1.__3.0__ * *Major changes* are treated as MAJOR: __1__.2.3 -> __2.0.0__ -## Getting started -Follow along as I go through the steps to play the game on your local computer from start to finish. I won't go into extreme detail, so if you don't have prior experience with Java or git, you might struggle. +## Quick start + +### Prerequisite + +1. [Docker](https://www.docker.com) +2. [Docker Compose](https://docs.docker.com/compose/install) + +### Set up environment variables + +Create `.env` file by copying the `.env.example` file: + +``` +cp .env.example .env +``` + +Then, check and edit the `.env` file if necessary. + +> Note: Though `.env.example` already contains default settings that make +> Cosmic work, it is recommended to set settings like `DB_PASS` with reasonable +> values. + +### Start services + +To start all services, run: + +```bash +docker compose up -d +``` + +Then you can use + +```bash +docker compose logs -f maplestory +``` + +to check the logs of Cosmic server. + +Once the server is ready, you will see a message like: + +```log +07:24:20.269 [main] INFO server.Server - Cosmic is now online after 14547 ms. +``` + +### Stop services + +```bash +docker compose down +``` + +### Rebuild + +You must rebuild images after any code changes: + +```bash +docker compose build +``` + +## Local setup +You can also run Cosmic on your actual machine. We will set up the following: - Database - the database is used by the server to store game data such as accounts, characters and inventory items. - Server - the server is the "brain" and routes network traffic between the clients. -- Client - the client is the application used to _play the game_, i.e. MapleStory.exe. -### 1 - Database +### 1 - Database You will start by installing the database server and database client. Then you will connect to the server with the client to create a new database schema. #### Steps @@ -82,17 +138,12 @@ You will start by cloning the repository, then configure the database properties #### Steps 1. Clone Cosmic into a new project. In IntelliJ, you would create a new project from version control. -2. Open _config.yaml_. Find "DB_PASS" and set it to your database root user password. +2. [Create `.env` file](#set-up-environment-variables) and then load it into current shell session by `. .env` command. 3. Start the server. The main method is located in `net.server.Server`. 4. If you see "Cosmic is now online" in the console, it means the server is online and ready to serve traffic. Yay! Below, I list other ways of running the server which are completely optional. -#### Docker -Support for Docker is also provided out of the box, as an alternative to running straight in the IDE. If you have [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed it's as easy as running `docker compose up`. - -Making changes becomes a bit more tedious though as you have to rebuild the server image via `docker compose up --build`. - #### Jar Another option is to start the server from a terminal by running a jar file. You first need to build the jar file from source which requires [Maven](https://maven.apache.org/). Fortunately, [Maven Wrapper](https://maven.apache.org/wrapper/) is provided so you don't have to install Maven separately. @@ -102,15 +153,17 @@ To run the jar, a ``launch.bat`` file is provided for convenience. Simply double Alternatively, run the jar file from the terminal. Just remember to provide the `wz-path` system property pointing to your wz directory. -### 3 - Client -The client files are located in a separate repository: https://github.com/P0nk/Cosmic-client +## Client +Client is the application used to _play the game_, i.e. MapleStory.exe. + +The files are located in a separate repository: https://github.com/P0nk/Cosmic-client Follow the installation guide in the README. -### 4 - Getting into the game -You have successfully started the client, and you're looking at the login screen. +## Getting into the game +You have successfully started the client, and you're looking at the login screen. -#### Logging in +### Logging in At this point, you can log in to the admin account using the following credentials: * Username: "admin" * Password: "admin" @@ -119,7 +172,7 @@ At this point, you can log in to the admin account using the following credentia You can also create a new regular account by typing in your desired username & password and attempting to log in. This "automatic registration" feature lets you create new accounts to play around with. It is enabled by default (see _config.yaml_). -#### Entering the game +### Entering the game Create a new character as you normally would, and then select it to enter the game. Hooray, finally we're in! If you log in to the "Admin" character, you'll notice that the character looks almost invisible. This is hide mode, which is enabled by default when you log in to a GM character. You won't be visible to normal players and no mobs will move if you're alone on the map. Toggle hide mode on or off by typing "@hide" in the in-game chat. diff --git a/config.yaml b/config.yaml index f46841921b3..192534e5929 100644 --- a/config.yaml +++ b/config.yaml @@ -159,10 +159,11 @@ worlds: server: #Database Configuration - DB_URL_FORMAT: "jdbc:mysql://%s:3306/cosmic" # If the docker ENV for DB_HOST is anything but "db", this string format should be changed from 3306 to 3307 (or whichever port it was changed to in docker) - DB_HOST: "localhost" - DB_USER: "root" - DB_PASS: "" + DB_HOST: "${DB_HOST:db}" + DB_PORT: "${DB_PORT:3306}" + DB_NAME: "${DB_NAME:cosmic}" + DB_USER: "${DB_USER:root}" + DB_PASS: "${DB_PASS:}" INIT_CONNECTION_POOL_TIMEOUT: 90 # Seconds #Login Configuration diff --git a/docker-compose.yml b/docker-compose.yml index a872b6dc839..869b820033b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,16 +17,16 @@ services: - ./config.yaml:/opt/server/config.yaml - ./scripts:/opt/server/scripts - ./wz:/opt/server/wz - environment: - DB_HOST: "db" ## Remember if this is present it will OVERRIDE the host in the config.yaml, if you put here anything other than db, you'll need to change the config.yaml jdbc string to port 3307, and not port 3306 - + env_file: + - ./.env + db: image: mysql:8.4.0 environment: - MYSQL_DATABASE: "cosmic" - MYSQL_ROOT_PASSWORD: "" + MYSQL_DATABASE: "${DB_NAME}" + MYSQL_ROOT_PASSWORD: "${DB_PASS}" MYSQL_ALLOW_EMPTY_PASSWORD: yes ports: - - "3307:3306" + - "${DB_PORT}:3306" volumes: - ./database/docker-db-data:/var/lib/mysql diff --git a/pom.xml b/pom.xml index 945a417fd89..44809ad9589 100644 --- a/pom.xml +++ b/pom.xml @@ -63,12 +63,14 @@ 4.2.2.Final 1.17 1.0 + 1.15.0 6.3.0 9.3.0 3.49.5 4.32.0 5.13.1 5.18.0 + 1.3.1 @@ -82,6 +84,11 @@ jcip-annotations ${jcip-annotations.version} + + org.apache.commons + commons-text + ${commons-text.version} + @@ -192,6 +199,12 @@ ${mockito.version} test + + com.google.jimfs + jimfs + ${jimfs.version} + test + diff --git a/src/main/java/config/ServerConfig.java b/src/main/java/config/ServerConfig.java index 036dae258d7..a79a3e69451 100644 --- a/src/main/java/config/ServerConfig.java +++ b/src/main/java/config/ServerConfig.java @@ -6,8 +6,9 @@ public class ServerConfig { //Database Configuration - public String DB_URL_FORMAT; public String DB_HOST; + public int DB_PORT; + public String DB_NAME; public String DB_USER; public String DB_PASS; public int INIT_CONNECTION_POOL_TIMEOUT; diff --git a/src/main/java/config/YamlConfig.java b/src/main/java/config/YamlConfig.java index 2263eb664e7..dfdb685b908 100644 --- a/src/main/java/config/YamlConfig.java +++ b/src/main/java/config/YamlConfig.java @@ -2,31 +2,50 @@ import com.esotericsoftware.yamlbeans.YamlReader; import constants.string.CharsetConstants; +import tools.EnvironmentVariables; -import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import org.apache.commons.text.StringSubstitutor; + public class YamlConfig { public static final String CONFIG_FILE_NAME = "config.yaml"; - public static final YamlConfig config = loadConfig(); + public static final YamlConfig config = load(Path.of(CONFIG_FILE_NAME)); public List worlds; public ServerConfig server; - private static YamlConfig loadConfig() { + static YamlConfig load(Path path) { + String content = replaceEnvironmentVariables(readFile(path)); + + try (YamlReader reader = new YamlReader(content)) { + return reader.read(YamlConfig.class); + } catch (IOException e) { + throw new RuntimeException( + "Failed to parse config file " + path.toString() + + ": " + e.getMessage() + ); + } + } + + private static String readFile(Path path) { try { - YamlReader reader = new YamlReader(Files.newBufferedReader(Path.of(CONFIG_FILE_NAME), CharsetConstants.CHARSET)); - YamlConfig config = reader.read(YamlConfig.class); - reader.close(); - return config; - } catch (FileNotFoundException e) { - throw new RuntimeException("Could not read config file " + YamlConfig.CONFIG_FILE_NAME + ": " + e.getMessage()); + return Files.readString(path, CharsetConstants.CHARSET); } catch (IOException e) { - throw new RuntimeException("Could not successfully parse config file " + YamlConfig.CONFIG_FILE_NAME + ": " + e.getMessage()); + throw new RuntimeException( + "Failed to read config file " + path.toString() + + ": " + e.getMessage() + ); } } + + private static String replaceEnvironmentVariables(String content) { + return new StringSubstitutor(EnvironmentVariables.instance().getAll()) + .setValueDelimiter(':') + .replace(content); + } } diff --git a/src/main/java/tools/DatabaseConnection.java b/src/main/java/tools/DatabaseConnection.java index b0313b92fac..e60677a0ebe 100644 --- a/src/main/java/tools/DatabaseConnection.java +++ b/src/main/java/tools/DatabaseConnection.java @@ -44,12 +44,12 @@ public static Handle getHandle() { } private static String getDbUrl() { - // Environment variables override what's defined in the config file - // This feature is used for the Docker support - String hostOverride = System.getenv("DB_HOST"); - String host = hostOverride != null ? hostOverride : YamlConfig.config.server.DB_HOST; - String dbUrl = String.format(YamlConfig.config.server.DB_URL_FORMAT, host); - return dbUrl; + return String.format( + "jdbc:mysql://%s:%d/%s", + YamlConfig.config.server.DB_HOST, + YamlConfig.config.server.DB_PORT, + YamlConfig.config.server.DB_NAME + ); } private static HikariConfig getConfig() { diff --git a/src/main/java/tools/EnvironmentVariables.java b/src/main/java/tools/EnvironmentVariables.java new file mode 100644 index 00000000000..c517fb16c89 --- /dev/null +++ b/src/main/java/tools/EnvironmentVariables.java @@ -0,0 +1,35 @@ +package tools; + +import java.util.Map; + +/** + * Wrapper class for accessing environment variables. + * + * Using this class instead of calling built-in functions like `System.getenv()` + * make it possible to mock environment variables in tests. + * + * Note that environment variables should only be accessed in limited places, + * like configuration loading. In most places, you should use config instead + * of environment variables. + */ +public class EnvironmentVariables { + private static EnvironmentVariables instance; + + private EnvironmentVariables() {} + + public static synchronized EnvironmentVariables instance() { + if (instance == null) { + instance = new EnvironmentVariables(); + } + + return instance; + } + + public static void setInstance(EnvironmentVariables instance) { + EnvironmentVariables.instance = instance; + } + + public Map getAll() { + return System.getenv(); + } +} diff --git a/src/test/java/config/YamlConfigTest.java b/src/test/java/config/YamlConfigTest.java new file mode 100644 index 00000000000..798bea585ad --- /dev/null +++ b/src/test/java/config/YamlConfigTest.java @@ -0,0 +1,153 @@ +package config; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import constants.string.CharsetConstants; +import tools.EnvironmentVariables; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class YamlConfigTest { + private FileSystem fs; + + @BeforeEach + void setUp() { + fs = Jimfs.newFileSystem(Configuration.unix()); + } + + @AfterEach + void tearDown() throws IOException { + fs.close(); + } + + @Test + void load_noEnvironmentVariables() throws IOException { + int flag = 1; + String serverMessage = "Hello World"; + int channels = 2; + String dbHost = "localhost"; + String dbUser = "user"; + String dbPass = "pass"; + int worlds = 2; + String yamlContent = String.format( + """ + worlds: + - flag: %d + server_message: "%s" + channels: %d + - flag: 0 + server_message: "Another World" + channels: 1 + server: + DB_HOST: "%s" + DB_USER: "%s" + DB_PASS: "%s" + WORLDS: %d + """, + flag, serverMessage, channels, dbHost, dbUser, dbPass, worlds + ); + Path configPath = fs.getPath("/config.yaml"); + Files.writeString(configPath, yamlContent, CharsetConstants.CHARSET); + + YamlConfig config = YamlConfig.load(configPath); + + assertNotNull(config.worlds); + assertEquals(2, config.worlds.size()); + assertEquals(1, config.worlds.get(0).flag); + assertEquals(serverMessage, config.worlds.get(0).server_message); + assertEquals(channels, config.worlds.get(0).channels); + + assertNotNull(config.server); + assertEquals(dbHost, config.server.DB_HOST); + assertEquals(dbUser, config.server.DB_USER); + assertEquals(dbPass, config.server.DB_PASS); + assertEquals(worlds, config.server.WORLDS); + } + + @Test + void load_replaceEnvironmentVariables() throws IOException { + String dbUser = "user"; + String dbPass = "pass"; + mockEnvironmentVariables(Map.of( + "DB_USER", dbUser, + "DB_PASS", dbPass + )); + + String yamlContent = + """ + server: + DB_USER: "${DB_USER}" + DB_PASS: "${DB_PASS}" + """; + Path configPath = fs.getPath("/config.yaml"); + Files.writeString(configPath, yamlContent, CharsetConstants.CHARSET); + + YamlConfig config = YamlConfig.load(configPath); + assertNotNull(config.server); + assertEquals(dbUser, config.server.DB_USER); + assertEquals(dbPass, config.server.DB_PASS); + } + + private void mockEnvironmentVariables(Map envVars) { + EnvironmentVariables mock = Mockito.mock(EnvironmentVariables.class); + Mockito.when(mock.getAll()).thenReturn(envVars); + EnvironmentVariables.setInstance(mock); + } + + @Test + void load_useDefaultValueForEnvironmentVariablesNotPresent() throws IOException { + EnvironmentVariables.setInstance(null); + + String dbUser = "user"; + String dbPass = "pass"; + String yamlContent = String.format( + """ + server: + DB_USER: "${DB_USER:%s}" + DB_PASS: "${DB_PASS:%s}" + """, + dbUser, dbPass + ); + Path configPath = fs.getPath("/config.yaml"); + Files.writeString(configPath, yamlContent, CharsetConstants.CHARSET); + + YamlConfig config = YamlConfig.load(configPath); + assertNotNull(config.server); + assertEquals(dbUser, config.server.DB_USER); + assertEquals(dbPass, config.server.DB_PASS); + } + + @Test + void load_throwExceptionWhenFileDoesNotExist() { + Path configPath = fs.getPath("/config.yaml"); + + assertThrows(RuntimeException.class, () -> { + YamlConfig.load(configPath); + }); + } + + @Test + void load_throwExceptionWhenFailedToParseConfig() throws IOException { + Path configPath = fs.getPath("/config.yaml"); + Files.writeString(configPath, "invalid_field: value", CharsetConstants.CHARSET); + + assertThrows(RuntimeException.class, () -> { + YamlConfig.load(configPath); + }); + } +} diff --git a/src/test/java/tools/EnvironmentVariablesTest.java b/src/test/java/tools/EnvironmentVariablesTest.java new file mode 100644 index 00000000000..3e99b01756e --- /dev/null +++ b/src/test/java/tools/EnvironmentVariablesTest.java @@ -0,0 +1,44 @@ +package tools; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +public class EnvironmentVariablesTest { + @AfterEach + void tearDown() { + EnvironmentVariables.setInstance(null); + } + + @Test + void instance_providesDefaultImplementation() { + EnvironmentVariables instance = EnvironmentVariables.instance(); + assertNotNull(instance, "Instance should not be null"); + assertEquals(instance.getAll(), System.getenv(), "getAll() should return all environment variables"); + } + + @Test + void instance_returnsSameSingleton() { + EnvironmentVariables first = EnvironmentVariables.instance(); + EnvironmentVariables second = EnvironmentVariables.instance(); + assertSame(first, second, "The singleton should return the same instance on subsequent calls"); + } + + @Test + void setInstance_allowsMocking() { + EnvironmentVariables mock = Mockito.mock(EnvironmentVariables.class); + String dbUser = "user"; + String dbPass = "pass"; + when(mock.getAll()).thenReturn(Map.of("DB_USER", dbUser, "DB_PASS", dbPass)); + + EnvironmentVariables.setInstance(mock); + + Map vars = EnvironmentVariables.instance().getAll(); + assertEquals(dbUser, vars.get("DB_USER")); + assertEquals(dbPass, vars.get("DB_PASS")); + } +}