diff --git a/M4/Part1/Client.java b/M4/Part1/Client.java
new file mode 100644
index 0000000..ce12481
--- /dev/null
+++ b/M4/Part1/Client.java
@@ -0,0 +1,175 @@
+package M4.Part1;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.Scanner;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Demoing single direction of Client sending data to a Server
+ */
+public class Client {
+
+ private Socket server = null;
+ private PrintWriter out = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})"); // 192.168.0.2:3000
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})"); // localhost:3000
+ private boolean isRunning = false;
+
+ public Client() {
+ System.out.println("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these check the client's end of the socket connect; therefore they
+ // don't really help determine
+ // if the server had a problem and is just for lesson's sake
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+
+ }
+
+ /**
+ * Takes an ip address and a port to attempt a socket connection to a server.
+ *
+ * @param address
+ * @param port
+ * @return true if connection was successful
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // channel to send to server
+ out = new PrintWriter(server.getOutputStream(), true);
+ // channel to list to server
+ System.out.println("Client connected");
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Check if the string contains the connect command
+ * followed by an ip address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text
+ * @return
+ */
+ private boolean isConnection(String text) {
+ // https://www.w3schools.com/java/java_regex.asp
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Controller for handling various text commands.
+ *
+ * Add more here as needed
+ *
+ *
+ * @param text
+ * @return true if a text was a command or triggered a command
+ */
+ private boolean processClientCommand(String text) {
+ if (isConnection(text)) {
+ // replaces multiple spaces with single space
+ // splits on the space after connect (gives us host and port)
+ // splits on : to get host as index 0 and port as index 1
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ return true;
+ } else if ("/quit".equalsIgnoreCase(text)) {
+ isRunning = false;
+ return true;
+ }
+ return false;
+ }
+
+ public void start() throws IOException {
+
+ System.out.println("Client starting");
+ try (Scanner si = new Scanner(System.in);) {
+ String line = "";
+ isRunning = true;
+ while (isRunning) {
+ try {
+ System.out.println("Waiting for input");
+ line = si.nextLine();
+ if (!processClientCommand(line)) {
+ if (isConnected()) {
+ out.println(line);
+ // https://stackoverflow.com/a/8190411
+ // you'll notice it triggers on the second request after server socket closes
+ if (out.checkError()) {
+ System.out.println("Connection to server may have been lost");
+ }
+ } else {
+ System.out.println("Not connected to server");
+ }
+ }
+ } catch (Exception e) {
+ System.out.println("Connection dropped");
+ break;
+ }
+ }
+ System.out.println("Exited loop");
+ } catch (Exception e) {
+ System.out.println("Exception from start()");
+ e.printStackTrace();
+ } finally {
+ close();
+ }
+ }
+
+ private void close() {
+ try {
+ System.out.println("Closing output stream");
+ out.close();
+ } catch (NullPointerException ne) {
+ System.out.println("Server was never opened so this exception is ok");
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ System.out.println("Closing connection");
+ server.close();
+ System.out.println("Closed socket");
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (NullPointerException ne) {
+ System.out.println("Server was never opened so this exception is ok");
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = new Client();
+
+ try {
+ // if start is private, it's valid here since this main is part of the class
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/M4/Part1/Server.java b/M4/Part1/Server.java
new file mode 100644
index 0000000..3a032cb
--- /dev/null
+++ b/M4/Part1/Server.java
@@ -0,0 +1,56 @@
+package M4.Part1;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.ServerSocket;
+import java.net.Socket;
+
+public class Server {
+ private int port = 3000;
+
+ private void start(int port) {
+ this.port = port;
+ System.out.println("Listening on port " + this.port);
+ // server listening
+ try (ServerSocket serverSocket = new ServerSocket(port);
+ // client wait
+ Socket client = serverSocket.accept(); // blocking;
+
+ // read from client
+ BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));) {
+
+ System.out.println("Client connected, waiting for message");
+ String fromClient = "";
+ while ((fromClient = in.readLine()) != null) {
+ if ("/kill server".equalsIgnoreCase(fromClient)) {
+ // normally you wouldn't have a remote kill command, this is just for example
+ // sake
+ System.out.println("Client killed server");
+ break;
+ } else {
+ System.out.println("From client: " + fromClient);
+ }
+ }
+ } catch (IOException e) {
+ System.out.println("Exception from start()");
+ e.printStackTrace();
+ } finally {
+ System.out.println("closing server socket");
+ }
+ }
+
+ public static void main(String[] args) {
+ System.out.println("Server Starting");
+ Server server = new Server();
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can ignore, will either be index out of bounds or type mismatch
+ // will default to the defined value prior to the try/catch
+ }
+ server.start(port);
+ System.out.println("Server Stopped");
+ }
+}
\ No newline at end of file
diff --git a/M4/Part2/Client.java b/M4/Part2/Client.java
new file mode 100644
index 0000000..8f53489
--- /dev/null
+++ b/M4/Part2/Client.java
@@ -0,0 +1,200 @@
+package M4.Part2;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.Scanner;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Demoing bi-directional communication between client and server
+ */
+public class Client {
+
+ private Socket server = null;
+ private PrintWriter out = null;
+ private BufferedReader in = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})");
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})");
+ private boolean isRunning = false;
+
+ public Client() {
+ System.out.println("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these check the client's end of the socket connect; therefore they
+ // don't really help determine
+ // if the server had a problem and is just for lesson's sake
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+
+ }
+
+ /**
+ * Takes an ip address and a port to attempt a socket connection to a server.
+ *
+ * @param address
+ * @param port
+ * @return true if connection was successful
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // channel to send to server
+ out = new PrintWriter(server.getOutputStream(), true);
+ // channel to list to server
+ in = new BufferedReader(new InputStreamReader(server.getInputStream()));
+ System.out.println("Client connected");
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Check if the string contains the connect command
+ * followed by an ip address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text
+ * @return
+ */
+ private boolean isConnection(String text) {
+ // https://www.w3schools.com/java/java_regex.asp
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Controller for handling various text commands.
+ *
+ * Add more here as needed
+ *
+ *
+ * @param text
+ * @return true if a text was a command or triggered a command
+ */
+ private boolean processClientCommand(String text) {
+ if (isConnection(text)) {
+ // replaces multiple spaces with single space
+ // splits on the space after connect (gives us host and port)
+ // splits on : to get host as index 0 and port as index 1
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ return true;
+ } else if ("/quit".equalsIgnoreCase(text)) {
+ isRunning = false;
+ return true;
+ }
+ return false;
+ }
+
+ public void start() throws IOException {
+
+ System.out.println("Client starting");
+ try (Scanner si = new Scanner(System.in);) {
+ String line = "";
+ isRunning = true;
+ while (isRunning) {
+ try {
+ System.out.println("Waiting for input");
+ line = si.nextLine();
+ if (!processClientCommand(line)) {
+ if (isConnected()) {
+ out.println(line);
+ // https://stackoverflow.com/a/8190411
+ // you'll notice it triggers on the second request after server socket closes
+ if (out.checkError()) {
+ System.out.println("Connection to server may have been lost");
+ }
+ // wait for reply
+ // Note: now that we're attempting a read
+ // we'll immediately get notified if the server's connection closes
+ // Note2: if the server terminates before we send a message, client will exit
+ // after the out.println() continues
+ String fromServer = in.readLine();
+
+ if (fromServer != null) {
+ System.out.println("Reply from server: " + fromServer);
+ } else {
+ System.out.println("Server disconnected");
+ break;
+ }
+ } else {
+ System.out.println("Not connected to server");
+ }
+ }
+ } catch (Exception e) {
+ System.out.println("Connection dropped");
+ break;
+ }
+ }
+ System.out.println("Exited loop");
+ } catch (Exception e) {
+ System.out.println("Exception from start()");
+ e.printStackTrace();
+ } finally {
+ close();
+ }
+ }
+
+ private void close() {
+ try {
+ System.out.println("Closing output stream");
+ out.close();
+ } catch (NullPointerException ne) {
+ System.out.println("Outputstream was never opened so this exception is ok");
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ System.out.println("Closing input stream");
+ in.close();
+ } catch (NullPointerException ne) {
+ System.out.println("InputStream was never opened so this exception is ok");
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ System.out.println("Closing connection");
+ server.close();
+ System.out.println("Closed socket");
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (NullPointerException ne) {
+ System.out.println("Server was never opened so this exception is ok");
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = new Client();
+
+ try {
+ // if start is private, it's valid here since this main is part of the class
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/M4/Part2/Server.java b/M4/Part2/Server.java
new file mode 100644
index 0000000..39b3a61
--- /dev/null
+++ b/M4/Part2/Server.java
@@ -0,0 +1,70 @@
+package M4.Part2;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.net.ServerSocket;
+import java.net.Socket;
+
+public class Server {
+ private int port = 3000;
+
+ private void start(int port) {
+ this.port = port;
+ System.out.println("Listening on port " + this.port);
+ // server listening
+ try (ServerSocket serverSocket = new ServerSocket(port);
+ // client wait
+ Socket client = serverSocket.accept(); // blocking;
+ // send to client
+ PrintWriter out = new PrintWriter(client.getOutputStream(), true);
+ // read from client
+ BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));) {
+
+ System.out.println("Client connected, waiting for message");
+ String fromClient = "";
+ while ((fromClient = in.readLine()) != null) {
+ System.out.println("From client: " + fromClient);
+ if ("/kill server".equalsIgnoreCase(fromClient)) {
+ // normally you wouldn't have a remote kill command, this is just for example
+ // sake
+ System.out.println("Client killed server");
+ break;
+ } else if (fromClient.startsWith("/reverse")) {
+ // another example of server-side command
+ // Note: In the future command format processing will be client-side
+ // then client will send just the necessary data to the server so the server
+ // doesn't need to do as much string processing
+ StringBuilder sb = new StringBuilder(fromClient.replace("/reverse ", ""));
+ sb.reverse();
+ String rev = sb.toString();
+ System.out.println("To client: " + rev);
+ out.println(rev);
+ } else {
+ System.out.println("To client: " + fromClient);
+ out.println(fromClient);
+ }
+ }
+ } catch (IOException e) {
+ System.out.println("Exception from start()");
+ e.printStackTrace();
+ } finally {
+ System.out.println("closing server socket");
+ }
+ }
+
+ public static void main(String[] args) {
+ System.out.println("Server Starting");
+ Server server = new Server();
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can ignore, will either be index out of bounds or type mismatch
+ // will default to the defined value prior to the try/catch
+ }
+ server.start(port);
+ System.out.println("Server Stopped");
+ }
+}
\ No newline at end of file
diff --git a/M4/Part3/Client.java b/M4/Part3/Client.java
new file mode 100644
index 0000000..45197b3
--- /dev/null
+++ b/M4/Part3/Client.java
@@ -0,0 +1,245 @@
+package M4.Part3;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.Scanner;
+import java.util.concurrent.CompletableFuture;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import M4.Part3.TextFX.Color;
+
+/**
+ * Demoing bi-directional communication between client and server in a
+ * multi-client scenario
+ */
+public class Client {
+
+ private Socket server = null;
+ private ObjectOutputStream out = null;
+ private ObjectInputStream in = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})");
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})");
+ private volatile boolean isRunning = true; // volatile for thread-safe visibility
+
+ public Client() {
+ System.out.println("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these check the client's end of the socket connect; therefore they
+ // don't really help determine if the server had a problem
+ // and is just for lesson's sake
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+ }
+
+ /**
+ * Takes an IP address and a port to attempt a socket connection to a server.
+ *
+ * @param address
+ * @param port
+ * @return true if connection was successful
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // channel to send to server
+ out = new ObjectOutputStream(server.getOutputStream());
+ // channel to listen to server
+ in = new ObjectInputStream(server.getInputStream());
+ System.out.println("Client connected");
+ // Use CompletableFuture to run listenToServer() in a separate thread
+ CompletableFuture.runAsync(this::listenToServer);
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Check if the string contains the connect command
+ * followed by an IP address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text
+ * @return true if the text is a valid connection command
+ */
+ private boolean isConnection(String text) {
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Controller for handling various text commands.
+ *
+ * Add more here as needed
+ *
+ *
+ * @param text
+ * @return true if the text was a command or triggered a command
+ * @throws IOException
+ */
+ private boolean processClientCommand(String text) throws IOException {
+ boolean wasCommand = false;
+ if (isConnection(text)) {
+ // replaces multiple spaces with a single space
+ // splits on the space after connect (gives us host and port)
+ // splits on : to get host as index 0 and port as index 1
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ wasCommand = true;
+ } else if ("/quit".equalsIgnoreCase(text)) {
+ close();
+ wasCommand = true;
+ } else if ("/disconnect".equalsIgnoreCase(text)) {
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, "disconnect" };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ } else if (text.startsWith("/reverse")) {
+ text = text.replace("/reverse", "").trim();
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, "reverse", text };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ }
+ return wasCommand;
+ }
+
+ public void start() throws IOException {
+ System.out.println("Client starting");
+
+ // Use CompletableFuture to run listenToInput() in a separate thread
+ CompletableFuture inputFuture = CompletableFuture.runAsync(this::listenToInput);
+
+ // Wait for inputFuture to complete to ensure proper termination
+ inputFuture.join();
+ }
+
+ /**
+ * Listens for messages from the server
+ */
+ private void listenToServer() {
+ try {
+ while (isRunning && isConnected()) {
+ String fromServer = (String) in.readObject(); // blocking read
+ if (fromServer != null) {
+ System.out.println(TextFX.colorize(fromServer, Color.BLUE));
+ } else {
+ System.out.println("Server disconnected");
+ break;
+ }
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (isRunning) {
+ System.out.println("Connection dropped");
+ e.printStackTrace();
+ }
+ } finally {
+ closeServerConnection();
+ }
+ System.out.println("listenToServer thread stopped");
+ }
+
+ /**
+ * Listens for keyboard input from the user
+ */
+ private void listenToInput() {
+ try (Scanner si = new Scanner(System.in)) {
+ System.out.println("Waiting for input"); // moved here to avoid console spam
+ while (isRunning) { // Run until isRunning is false
+ String userInput = si.nextLine();
+ if (!processClientCommand(userInput)) {
+ sendToServer(userInput);
+ }
+ }
+ } catch (IOException ioException) {
+ System.out.println("Error in listentToInput()");
+ ioException.printStackTrace();
+ }
+ System.out.println("listenToInput thread stopped");
+ }
+
+ private void sendToServer(String message) throws IOException {
+ if (isConnected()) {
+ out.writeObject(message);
+ out.flush(); // good practice to ensure data is written out immediately
+ } else {
+ System.out.println(
+ "Not connected to server (hint: type `/connect host:port` without the quotes and replace host/port with the necessary info)");
+ }
+ }
+
+ /**
+ * Closes the client connection and associated resources
+ */
+ private void close() {
+ isRunning = false;
+ closeServerConnection();
+ System.out.println("Client terminated");
+ // System.exit(0); // Terminate the application
+ }
+
+ /**
+ * Closes the server connection and associated resources
+ */
+ private void closeServerConnection() {
+ try {
+ if (out != null) {
+ System.out.println("Closing output stream");
+ out.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (in != null) {
+ System.out.println("Closing input stream");
+ in.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (server != null) {
+ System.out.println("Closing connection");
+ server.close();
+ System.out.println("Closed socket");
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = new Client();
+ try {
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/M4/Part3/Constants.java b/M4/Part3/Constants.java
new file mode 100644
index 0000000..8b285de
--- /dev/null
+++ b/M4/Part3/Constants.java
@@ -0,0 +1,5 @@
+package M4.Part3;
+
+public abstract class Constants {
+ final public static String COMMAND_TRIGGER = "[cmd]";
+}
\ No newline at end of file
diff --git a/M4/Part3/Server.java b/M4/Part3/Server.java
new file mode 100644
index 0000000..d7c294f
--- /dev/null
+++ b/M4/Part3/Server.java
@@ -0,0 +1,149 @@
+package M4.Part3;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class Server {
+ private int port = 3000;
+ // connected clients
+ // Use ConcurrentHashMap for thread-safe client management
+ // the Long will be a unique client identifier, and ServerThread is the instance
+ private final ConcurrentHashMap connectedClients = new ConcurrentHashMap<>();
+ private boolean isRunning = true;
+
+ private void start(int port) {
+ this.port = port;
+ // server listening
+ System.out.println("Listening on port " + this.port);
+ // Simplified client connection loop
+ try (ServerSocket serverSocket = new ServerSocket(port)) {
+ while (isRunning) {
+ System.out.println("Waiting for next client");
+ Socket incomingClient = serverSocket.accept(); // blocking action, waits for a client connection
+ System.out.println("Client connected");
+ // wrap socket in a ServerThread, pass a callback to notify the Server when
+ // they're initialized
+ ServerThread serverThread = new ServerThread(incomingClient, this, this::onServerThreadInitialized);
+ // start the thread (typically an external entity manages the lifecycle and we
+ // don't have the thread start itself)
+ serverThread.start();
+ // Note: We don't yet add the ServerThread reference to our connectedClients map
+ }
+ } catch (IOException e) {
+ System.err.println("Error accepting connection");
+ e.printStackTrace();
+ } finally {
+ System.out.println("Closing server socket");
+ }
+ }
+
+ /**
+ * Callback passed to ServerThread to inform Server they're ready to receive
+ * data
+ *
+ * @param serverThread
+ */
+ private void onServerThreadInitialized(ServerThread serverThread) {
+ // add to connected clients list (unique id and actual reference)
+ connectedClients.put(serverThread.getClientId(), serverThread);
+ relay(null, String.format("*User[%s] connected*", serverThread.getClientId()));
+ }
+
+ /**
+ * Takes a ServerThread and removes them from the Server
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param serverThread
+ */
+ private synchronized void disconnect(ServerThread serverThread) {
+ serverThread.disconnect();
+ // remove disconnecting ServerThread from map
+ ServerThread disconnectingServerThread = connectedClients.remove(serverThread.getClientId());
+ if (disconnectingServerThread != null) {
+ // Improved logging with user ID
+ relay(null, "User[" + disconnectingServerThread.getClientId() + "] disconnected");
+ }
+ }
+
+ /**
+ * Relays the message from the sender to all connectedClients
+ * Internally calls processCommand and evaluates as necessary.
+ * Note: Clients that fail to receive a message get removed from
+ * connectedClients.
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ private synchronized void relay(ServerThread sender, String message) {
+ // we'll temporarily use the thread id as the client identifier to
+ // show in all client's chat. This isn't good practice since it's subject to
+ // change as clients connect/disconnect (i.e., a reconnecting client likely
+ // won't get the same id)
+ // Note: any desired changes to the message must be done before this line
+ String senderString = sender == null ? "Server" : String.format("User[%s]", sender.getClientId());
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+ // end temp identifier
+
+ // loop over clients and send out the message; remove client if message failed
+ // to be sent
+ // Note: this uses a lambda expression for each item in the values() collection,
+ // it's one way we can safely remove items during iteration
+
+ connectedClients.values().removeIf(serverThread -> {
+ boolean failedToSend = !serverThread.sendToClient(formattedMessage);
+ if (failedToSend) {
+ System.out.println(
+ String.format("Removing disconnected client[%s] from list", serverThread.getClientId()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ // start handle actions
+ /**
+ * Expose access to the disconnect action
+ *
+ * @param serverThread
+ */
+ protected synchronized void handleDisconnect(ServerThread sender) {
+ disconnect(sender);
+ }
+
+ protected synchronized void handleReverseText(ServerThread sender, String text) {
+ StringBuilder sb = new StringBuilder(text);
+ sb.reverse();
+ String rev = sb.toString();
+ relay(sender, rev);
+ }
+
+ protected synchronized void handleMessage(ServerThread sender, String text) {
+ relay(sender, text);
+ }
+ // end handle actions
+
+ public static void main(String[] args) {
+ System.out.println("Server Starting");
+ Server server = new Server();
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can ignore, will either be index out of bounds or type mismatch
+ // will default to the defined value prior to the try/catch
+ }
+ server.start(port);
+ System.out.println("Server Stopped");
+ }
+
+}
\ No newline at end of file
diff --git a/M4/Part3/ServerThread.java b/M4/Part3/ServerThread.java
new file mode 100644
index 0000000..3c0d4ee
--- /dev/null
+++ b/M4/Part3/ServerThread.java
@@ -0,0 +1,225 @@
+package M4.Part3;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import M4.Part3.TextFX.Color;
+
+/**
+ * A server-side representation of a single client
+ */
+public class ServerThread extends Thread {
+ private Socket client; // communication directly to "my" client
+ private boolean isRunning = false; // control variable to stop this thread
+ private ObjectOutputStream out; // exposed here for send()
+ private Server server;// ref to our server so we can call methods on it
+ // more easily
+ private long clientId;
+ private Consumer onInitializationComplete; // callback to inform when this object is ready
+
+ /**
+ * A wrapper method so we don't need to keep typing out the long/complex sysout
+ * line inside
+ *
+ * @param message
+ */
+ private void info(String message) {
+ System.out.println(String.format("Thread[%s]: %s", this.getClientId(), message));
+ }
+
+ /**
+ * Returns the status of this ServerThread
+ *
+ * @return
+ */
+ public boolean isRunning() {
+ return isRunning;
+ }
+
+ /**
+ * Wraps the Socket connection and takes a Server reference and a callback
+ *
+ * @param myClient
+ * @param server
+ * @param onInitializationComplete method to inform listener that this object is
+ * ready
+ */
+ protected ServerThread(Socket myClient, Server server, Consumer onInitializationComplete) {
+ Objects.requireNonNull(myClient, "Client socket cannot be null");
+ Objects.requireNonNull(server, "Server cannot be null");
+ Objects.requireNonNull(onInitializationComplete, "callback cannot be null");
+ info("ServerThread created");
+ // get communication channels to single client
+ this.client = myClient;
+ this.server = server; // In the future we'll have a different way to reference the Server
+ this.clientId = this.threadId(); // An id associated with the thread instance, used as a temporary identifier
+ this.onInitializationComplete = onInitializationComplete;
+
+ }
+
+ public long getClientId() {
+ // Note: We return clientId instead of threadId as we'll change this identifier
+ // in the future
+ return this.clientId;
+ }
+
+ /**
+ * One of the two ways to get this to exit the listen loop
+ */
+ protected void disconnect() {
+ if (!isRunning) {
+ // prevent multiple triggers if this gets called consecutively
+ return;
+ }
+ info("Thread being disconnected by server");
+ isRunning = false;
+ this.interrupt(); // breaks out of blocking read in the run() method
+ cleanup(); // good practice to ensure data is written out immediately
+ }
+
+ /**
+ * Sends the message over the socket
+ *
+ * @param message
+ * @return true if no errors were encountered
+ */
+ protected boolean sendToClient(String message) {
+ if (!isRunning) {
+ return false;
+ }
+ try {
+ out.writeObject(message);
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ info("Error sending message to client (most likely disconnected)");
+ // comment this out to inspect the stack trace
+ // e.printStackTrace();
+ cleanup();
+ return false;
+ }
+ }
+
+ @Override
+ public void run() {
+ info("Thread starting");
+ try (ObjectOutputStream out = new ObjectOutputStream(client.getOutputStream());
+ ObjectInputStream in = new ObjectInputStream(client.getInputStream());) {
+ this.out = out;
+ isRunning = true;
+ onInitializationComplete.accept(this); // Notify server that initialization is complete
+ String fromClient;
+ /**
+ * isRunning is a flag to let us manage the loop exit condition
+ * fromClient (in.readObject()) is a blocking method that waits until data is
+ * received
+ * - null would likely mean a disconnect so we use a "set and check" logic to
+ * alternatively exit the loop
+ */
+ while (isRunning) {
+ try {
+ fromClient = (String) in.readObject(); // blocking method
+ if (fromClient == null) {
+ throw new IOException("Connection interrupted"); // Specific exception for a clean break
+ } else {
+ info(TextFX.colorize("Received from my client: " + fromClient, Color.CYAN));
+ processPayload(fromClient);
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (Thread.currentThread().isInterrupted()) {
+ info("Thread interrupted during read (likely from the disconnect() method)");
+ break;
+ }
+ info("IO exception while reading from client");
+ e.printStackTrace();
+ break;
+ }
+ } // close while loop
+ } catch (Exception e) {
+ // happens when client disconnects
+ info("General Exception");
+ e.printStackTrace();
+ info("My Client disconnected");
+ } finally {
+ isRunning = false;
+ info("Exited thread loop. Cleaning up connection");
+ cleanup();
+ }
+ }
+
+ private void processPayload(String incoming) {
+ if (!processCommand(incoming)) {
+ // if not command; send message to all clients via Server
+ server.handleMessage(this, incoming);
+ }
+
+ }
+
+ /**
+ * Attempts to see if the message is a command and process its action
+ *
+ * @param message
+ * @param sender
+ * @return true if it was a command, false otherwise
+ */
+ private boolean processCommand(String message) {
+ boolean wasCommand = false; // control var to use as the return status
+
+ // using "[cmd]" as a temporary trigger until we update how the data is passed
+ // over the socket
+ if (message.startsWith(Constants.COMMAND_TRIGGER)) {
+ // expected format will be csv for now to keep it simple
+ String[] commandData = message.split(",");
+ if (commandData.length >= 2) {
+
+ // index 0 is the trigger word
+ // index 1 is the command
+ final String command = commandData[1].trim();
+ System.out.println(TextFX.colorize("Checking command: " + command, Color.YELLOW));
+ // index N are the data from the command
+ // Note: not all commands require data, some are simply actions/triggers to
+ // process like quit
+ switch (command) {
+ case "quit":
+ case "disconnect":
+ case "logout":
+ case "logoff":
+ server.handleDisconnect(this);
+ wasCommand = true;
+ break;
+ case "reverse":
+ // ignore the first two indexes (trigger, command)
+ String relevantText = String.join(" ", Arrays.copyOfRange(commandData, 2, commandData.length));
+ server.handleReverseText(this, relevantText);
+ wasCommand = true;
+ break;
+ // added more cases/breaks as needed for other commands
+ default:
+ break;
+ }
+ }
+
+ }
+ return wasCommand;
+ }
+
+ private void cleanup() {
+ info("ServerThread cleanup() start");
+ try {
+ // close server-side end of connection
+ client.close();
+ info("Closed Server-side Socket");
+ } catch (IOException e) {
+ info("Client already closed");
+ }
+ info("ServerThread cleanup() end");
+ }
+}
\ No newline at end of file
diff --git a/M4/Part3/TextFX.java b/M4/Part3/TextFX.java
new file mode 100644
index 0000000..2870b75
--- /dev/null
+++ b/M4/Part3/TextFX.java
@@ -0,0 +1,72 @@
+package M4.Part3;
+
+/**
+ * Utility to attempt to provide colored text in the terminal.
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+public abstract class TextFX {
+
+ /**
+ * TextFX.Color list of available colors
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+ public enum Color {
+ BLACK("\033[0;30m"),
+ RED("\033[0;31m"),
+ GREEN("\033[0;32m"),
+ YELLOW("\033[0;33m"),
+ BLUE("\033[0;34m"),
+ PURPLE("\033[0;35m"),
+ CYAN("\033[0;36m"),
+ WHITE("\033[0;37m");
+
+ private final String code;
+
+ Color(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+ }
+
+ public static final String RESET = "\033[0m";
+
+ /**
+ * Generates a String with the original message wrapped in the ASCII of the
+ * color and RESET
+ *
+ *
+ * Note: May not work for all terminals
+ *
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ *
+ * @param text Input text to colorize
+ * @param color Enum of Color choice from TextFX.Color
+ * @return wrapped String
+ */
+ public static String colorize(String text, Color color) {
+ StringBuilder builder = new StringBuilder();
+ builder.append(color.getCode());
+ builder.append(text);
+ builder.append(RESET);
+ return builder.toString();
+ }
+
+ public static void main(String[] args) {
+ // Example usage:
+ System.out.println(TextFX.colorize("Hello, world!", Color.RED));
+ System.out.println(TextFX.colorize("This is some blue text.", Color.BLUE));
+ System.out.println(TextFX.colorize("And this is green!", Color.GREEN));
+ }
+}
\ No newline at end of file
diff --git a/M4/Part3HW/Client.java b/M4/Part3HW/Client.java
new file mode 100644
index 0000000..da925cc
--- /dev/null
+++ b/M4/Part3HW/Client.java
@@ -0,0 +1,293 @@
+package M4.Part3HW;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.Scanner;
+import java.util.concurrent.CompletableFuture;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import M4.Part3HW.TextFX.Color;
+
+/**
+ * Demoing bi-directional communication between client and server in a
+ * multi-client scenario
+ */
+public class Client {
+
+ private Socket server = null;
+ private ObjectOutputStream out = null;
+ private ObjectInputStream in = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})");
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})");
+ private volatile boolean isRunning = true; // volatile for thread-safe visibility
+
+ public Client() {
+ System.out.println("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these check the client's end of the socket connect; therefore they
+ // don't really help determine if the server had a problem
+ // and is just for lesson's sake
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+ }
+
+ /**
+ * Takes an IP address and a port to attempt a socket connection to a server.
+ *
+ * @param address
+ * @param port
+ * @return true if connection was successful
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // channel to send to server
+ out = new ObjectOutputStream(server.getOutputStream());
+ // channel to listen to server
+ in = new ObjectInputStream(server.getInputStream());
+ System.out.println("Client connected");
+ // Use CompletableFuture to run listenToServer() in a separate thread
+ CompletableFuture.runAsync(this::listenToServer);
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Check if the string contains the connect command
+ * followed by an IP address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text
+ * @return true if the text is a valid connection command
+ */
+ private boolean isConnection(String text) {
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Controller for handling various text commands.
+ *
+ * Add more here as needed
+ *
+ *
+ * @param text
+ * @return true if the text was a command or triggered a command
+ * @throws IOException
+ */
+ // 10/21/25 - UCID rk975
+ // Steps taken to solve this: 1. add another elif statement
+ // 2. detect the user input for /flip command 3. create data array
+ // 4. join the array to a string 5. Send the command to the server
+ private boolean processClientCommand(String text) throws IOException {
+ boolean wasCommand = false;
+ if (isConnection(text)) {
+ // replaces multiple spaces with a single space
+ // splits on the space after connect (gives us host and port)
+ // splits on : to get host as index 0 and port as index 1
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ wasCommand = true;
+ } else if ("/quit".equalsIgnoreCase(text)) {
+ close();
+ wasCommand = true;
+ } else if ("/disconnect".equalsIgnoreCase(text)) {
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, "disconnect" };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ } else if (text.startsWith("/reverse")) {
+ text = text.replace("/reverse", "").trim();
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, "reverse", text };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ }
+
+ else if ("/flip".equalsIgnoreCase(text.trim())) {
+ String[] commandData = { Constants.COMMAND_TRIGGER, "flip" };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ }
+
+ // 10/21/25 - UCID rk975
+ // Steps taken to solve this: 1. Added support for new /pm command.
+ // 2. Parse target id and message from user input.
+ // 3. Format message and send it to the server.
+ // 4. ServerThread and Server handle the rest.
+
+ else if (text.startsWith("/pm ")) {
+ String[] parts = text.trim().split(" ", 3);
+ if (parts.length >= 3) {
+ String targetId = parts[1].trim();
+ String message = parts[2].trim();
+ String[] commandData = { Constants.COMMAND_TRIGGER, "pm", targetId, message };
+ sendToServer(String.join(",", commandData));
+ } else {
+ System.out.println("Usage: /pm ");
+ }
+ wasCommand = true;
+ }
+
+ // 10/21/25 - UCID rk975
+ // Added support for /shuffle
+ // Steps:
+ // 1) Detect "/shuffle"
+ // 2) Extract message text
+ // 3) Send as: [cmd],shuffle,
+
+ else if (text.startsWith("/shuffle")) {
+ String message = text.replaceFirst("(?i)^/shuffle\\s*", "").trim();
+ if (message.isEmpty()) {
+ System.out.println("Usage: /shuffle ");
+ } else {
+ String[] commandData = { Constants.COMMAND_TRIGGER, "shuffle", message };
+ sendToServer(String.join(",", commandData));
+ }
+ wasCommand = true;
+ }
+
+ return wasCommand;
+ }
+
+ public void start() throws IOException {
+ System.out.println("Client starting");
+
+ // Use CompletableFuture to run listenToInput() in a separate thread
+ CompletableFuture inputFuture = CompletableFuture.runAsync(this::listenToInput);
+
+ // Wait for inputFuture to complete to ensure proper termination
+ inputFuture.join();
+ }
+
+ /**
+ * Listens for messages from the server
+ */
+ private void listenToServer() {
+ try {
+ while (isRunning && isConnected()) {
+ String fromServer = (String) in.readObject(); // blocking read
+ if (fromServer != null) {
+ System.out.println(TextFX.colorize(fromServer, Color.BLUE));
+ } else {
+ System.out.println("Server disconnected");
+ break;
+ }
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (isRunning) {
+ System.out.println("Connection dropped");
+ e.printStackTrace();
+ }
+ } finally {
+ closeServerConnection();
+ }
+ System.out.println("listenToServer thread stopped");
+ }
+
+ /**
+ * Listens for keyboard input from the user
+ */
+ private void listenToInput() {
+ try (Scanner si = new Scanner(System.in)) {
+ System.out.println("Waiting for input"); // moved here to avoid console spam
+ while (isRunning) { // Run until isRunning is false
+ String userInput = si.nextLine();
+ if (!processClientCommand(userInput)) {
+ sendToServer(userInput);
+ }
+ }
+ } catch (IOException ioException) {
+ System.out.println("Error in listentToInput()");
+ ioException.printStackTrace();
+ }
+ System.out.println("listenToInput thread stopped");
+ }
+
+ private void sendToServer(String message) throws IOException {
+ if (isConnected()) {
+ out.writeObject(message);
+ out.flush(); // good practice to ensure data is written out immediately
+ } else {
+ System.out.println(
+ "Not connected to server (hint: type `/connect host:port` without the quotes and replace host/port with the necessary info)");
+ }
+ }
+
+ /**
+ * Closes the client connection and associated resources
+ */
+ private void close() {
+ isRunning = false;
+ closeServerConnection();
+ System.out.println("Client terminated");
+ // System.exit(0); // Terminate the application
+ }
+
+ /**
+ * Closes the server connection and associated resources
+ */
+ private void closeServerConnection() {
+ try {
+ if (out != null) {
+ System.out.println("Closing output stream");
+ out.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (in != null) {
+ System.out.println("Closing input stream");
+ in.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (server != null) {
+ System.out.println("Closing connection");
+ server.close();
+ System.out.println("Closed socket");
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = new Client();
+ try {
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/M4/Part3HW/Constants.java b/M4/Part3HW/Constants.java
new file mode 100644
index 0000000..796bbf6
--- /dev/null
+++ b/M4/Part3HW/Constants.java
@@ -0,0 +1,5 @@
+package M4.Part3HW;
+
+public abstract class Constants {
+ final public static String COMMAND_TRIGGER = "[cmd]";
+}
\ No newline at end of file
diff --git a/M4/Part3HW/Server.java b/M4/Part3HW/Server.java
new file mode 100644
index 0000000..853f285
--- /dev/null
+++ b/M4/Part3HW/Server.java
@@ -0,0 +1,215 @@
+package M4.Part3HW;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.Random;
+
+public class Server {
+ private int port = 3000;
+ // connected clients
+ // Use ConcurrentHashMap for thread-safe client management
+ // the Long will be a unique client identifier, and ServerThread is the instance
+ private final ConcurrentHashMap connectedClients = new ConcurrentHashMap<>();
+ private boolean isRunning = true;
+
+ private void start(int port) {
+ this.port = port;
+ // server listening
+ System.out.println("Listening on port " + this.port);
+ // Simplified client connection loop
+ try (ServerSocket serverSocket = new ServerSocket(port)) {
+ while (isRunning) {
+ System.out.println("Waiting for next client");
+ Socket incomingClient = serverSocket.accept(); // blocking action, waits for a client connection
+ System.out.println("Client connected");
+ // wrap socket in a ServerThread, pass a callback to notify the Server when
+ // they're initialized
+ ServerThread serverThread = new ServerThread(incomingClient, this, this::onServerThreadInitialized);
+ // start the thread (typically an external entity manages the lifecycle and we
+ // don't have the thread start itself)
+ serverThread.start();
+ // Note: We don't yet add the ServerThread reference to our connectedClients map
+ }
+ } catch (IOException e) {
+ System.err.println("Error accepting connection");
+ e.printStackTrace();
+ } finally {
+ System.out.println("Closing server socket");
+ }
+ }
+
+ /**
+ * Callback passed to ServerThread to inform Server they're ready to receive
+ * data
+ *
+ * @param serverThread
+ */
+ private void onServerThreadInitialized(ServerThread serverThread) {
+ // add to connected clients list (unique id and actual reference)
+ connectedClients.put(serverThread.getClientId(), serverThread);
+ relay(null, String.format("*User[%s] connected*", serverThread.getClientId()));
+ }
+
+ /**
+ * Takes a ServerThread and removes them from the Server
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param serverThread
+ */
+ private synchronized void disconnect(ServerThread serverThread) {
+ serverThread.disconnect();
+ // remove disconnecting ServerThread from map
+ ServerThread disconnectingServerThread = connectedClients.remove(serverThread.getClientId());
+ if (disconnectingServerThread != null) {
+ // Improved logging with user ID
+ relay(null, "User[" + disconnectingServerThread.getClientId() + "] disconnected");
+ }
+ }
+
+ /**
+ * Relays the message from the sender to all connectedClients
+ * Internally calls processCommand and evaluates as necessary.
+ * Note: Clients that fail to receive a message get removed from
+ * connectedClients.
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ private synchronized void relay(ServerThread sender, String message) {
+ // we'll temporarily use the thread id as the client identifier to
+ // show in all client's chat. This isn't good practice since it's subject to
+ // change as clients connect/disconnect (i.e., a reconnecting client likely
+ // won't get the same id)
+ // Note: any desired changes to the message must be done before this line
+ String senderString = sender == null ? "Server" : String.format("User[%s]", sender.getClientId());
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+ // end temp identifier
+
+ // loop over clients and send out the message; remove client if message failed
+ // to be sent
+ // Note: this uses a lambda expression for each item in the values() collection,
+ // it's one way we can safely remove items during iteration
+
+ connectedClients.values().removeIf(serverThread -> {
+ boolean failedToSend = !serverThread.sendToClient(formattedMessage);
+ if (failedToSend) {
+ System.out.println(
+ String.format("Removing disconnected client[%s] from list", serverThread.getClientId()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ // start handle actions
+ /**
+ * Expose access to the disconnect action
+ *
+ * @param serverThread
+ */
+ protected synchronized void handleDisconnect(ServerThread sender) {
+ disconnect(sender);
+ }
+
+ protected synchronized void handleReverseText(ServerThread sender, String text) {
+ StringBuilder sb = new StringBuilder(text);
+ sb.reverse();
+ String rev = sb.toString();
+ relay(sender, rev);
+ }
+
+ protected synchronized void handleMessage(ServerThread sender, String text) {
+ relay(sender, text);
+ }
+ // end handle actions
+ // 10/21/25 UCID - rk975
+ // Steps to solve: 1. Instantiated Random for pseudo-random generation.
+ // 2. Evaluated boolean to determine coin state.
+ // 3. Constructed formatted output string dynamically.
+ // 4. Invoked relay() to multicast message.
+
+ protected synchronized void handleFlipCommand(ServerThread sender) {
+ Random random = new Random();
+ String result = random.nextBoolean() ? "Heads" : "Tails";
+ String message = String.format("User[%s] flipped a coin and got %s", sender.getClientId(), result);
+ relay(null, message); // relay as server message
+ }
+ // 10/21/25 UCID - rk975
+ // Added new private message logic for /pm command
+ // Steps to solve:
+ // 1. Extract target user id and message text.
+ // 2. Locate receiver from connectedClients.
+ // 3. Construct "PM from : ".
+ // 4. Send only to sender and receiver.
+
+ protected synchronized void handlePrivateMessage(ServerThread sender, long targetId, String messageText) {
+ ServerThread receiver = connectedClients.get(targetId);
+
+ if (receiver == null) {
+ // Notify sender that the target user doesn't exist
+ sender.sendToClient("Server: User[" + targetId + "] not found or not connected.");
+ return;
+ }
+
+ String formatted = String.format("PM from User[%s]: %s", sender.getClientId(), messageText);
+
+ // Send to both sender and receiver only
+ sender.sendToClient("Server: " + formatted);
+ receiver.sendToClient("Server: " + formatted);
+ }
+
+ // 10/21/25 UCID - rk975
+ // Added handleShuffleMessage() for /shuffle command
+ // Steps:
+ // 1. Convert message string into a List.
+ // 2. Randomly shuffle characters using Collections.shuffle().
+ // 3. Join shuffled chars into a string.
+ // 4. Broadcast "Shuffled from : " from the Server.
+
+ protected synchronized void handleShuffleMessage(ServerThread sender, String text) {
+ if (text == null || text.trim().isEmpty()) {
+ sender.sendToClient("Server: Usage - /shuffle ");
+ return;
+ }
+
+ // Convert text to a list of characters for shuffling
+ List chars = new ArrayList<>();
+ for (char c : text.toCharArray()) {
+ chars.add(c);
+ }
+
+ Collections.shuffle(chars); // random shuffle
+ StringBuilder shuffled = new StringBuilder();
+ for (char c : chars) {
+ shuffled.append(c);
+ }
+
+ String shuffledMsg = String.format("Shuffled from User[%s]: %s", sender.getClientId(), shuffled.toString());
+ relay(null, shuffledMsg); // broadcast to all clients as server message
+ }
+
+ public static void main(String[] args) {
+ System.out.println("Server Starting");
+ Server server = new Server();
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can ignore, will either be index out of bounds or type mismatch
+ // will default to the defined value prior to the try/catch
+ }
+ server.start(port);
+ System.out.println("Server Stopped");
+ }
+
+}
\ No newline at end of file
diff --git a/M4/Part3HW/ServerThread.java b/M4/Part3HW/ServerThread.java
new file mode 100644
index 0000000..68a9cce
--- /dev/null
+++ b/M4/Part3HW/ServerThread.java
@@ -0,0 +1,272 @@
+package M4.Part3HW;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import M4.Part3HW.TextFX.Color;
+
+/**
+ * A server-side representation of a single client
+ */
+public class ServerThread extends Thread {
+ private Socket client; // communication directly to "my" client
+ private boolean isRunning = false; // control variable to stop this thread
+ private ObjectOutputStream out; // exposed here for send()
+ private Server server;// ref to our server so we can call methods on it
+ // more easily
+ private long clientId;
+ private Consumer onInitializationComplete; // callback to inform when this object is ready
+
+ /**
+ * A wrapper method so we don't need to keep typing out the long/complex sysout
+ * line inside
+ *
+ * @param message
+ */
+ private void info(String message) {
+ System.out.println(String.format("Thread[%s]: %s", this.getClientId(), message));
+ }
+
+ /**
+ * Returns the status of this ServerThread
+ *
+ * @return
+ */
+ public boolean isRunning() {
+ return isRunning;
+ }
+
+ /**
+ * Wraps the Socket connection and takes a Server reference and a callback
+ *
+ * @param myClient
+ * @param server
+ * @param onInitializationComplete method to inform listener that this object is
+ * ready
+ */
+ protected ServerThread(Socket myClient, Server server, Consumer onInitializationComplete) {
+ Objects.requireNonNull(myClient, "Client socket cannot be null");
+ Objects.requireNonNull(server, "Server cannot be null");
+ Objects.requireNonNull(onInitializationComplete, "callback cannot be null");
+ info("ServerThread created");
+ // get communication channels to single client
+ this.client = myClient;
+ this.server = server; // In the future we'll have a different way to reference the Server
+ this.clientId = this.threadId(); // An id associated with the thread instance, used as a temporary identifier
+ this.onInitializationComplete = onInitializationComplete;
+
+ }
+
+ public long getClientId() {
+ // Note: We return clientId instead of threadId as we'll change this identifier
+ // in the future
+ return this.clientId;
+ }
+
+ /**
+ * One of the two ways to get this to exit the listen loop
+ */
+ protected void disconnect() {
+ if (!isRunning) {
+ // prevent multiple triggers if this gets called consecutively
+ return;
+ }
+ info("Thread being disconnected by server");
+ isRunning = false;
+ this.interrupt(); // breaks out of blocking read in the run() method
+ cleanup(); // good practice to ensure data is written out immediately
+ }
+
+ /**
+ * Sends the message over the socket
+ *
+ * @param message
+ * @return true if no errors were encountered
+ */
+ protected boolean sendToClient(String message) {
+ if (!isRunning) {
+ return false;
+ }
+ try {
+ out.writeObject(message);
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ info("Error sending message to client (most likely disconnected)");
+ // comment this out to inspect the stack trace
+ // e.printStackTrace();
+ cleanup();
+ return false;
+ }
+ }
+
+ @Override
+ public void run() {
+ info("Thread starting");
+ try (ObjectOutputStream out = new ObjectOutputStream(client.getOutputStream());
+ ObjectInputStream in = new ObjectInputStream(client.getInputStream());) {
+ this.out = out;
+ isRunning = true;
+ onInitializationComplete.accept(this); // Notify server that initialization is complete
+ String fromClient;
+ /**
+ * isRunning is a flag to let us manage the loop exit condition
+ * fromClient (in.readObject()) is a blocking method that waits until data is
+ * received
+ * - null would likely mean a disconnect so we use a "set and check" logic to
+ * alternatively exit the loop
+ */
+ while (isRunning) {
+ try {
+ fromClient = (String) in.readObject(); // blocking method
+ if (fromClient == null) {
+ throw new IOException("Connection interrupted"); // Specific exception for a clean break
+ } else {
+ info(TextFX.colorize("Received from my client: " + fromClient, Color.CYAN));
+ processPayload(fromClient);
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (Thread.currentThread().isInterrupted()) {
+ info("Thread interrupted during read (likely from the disconnect() method)");
+ break;
+ }
+ info("IO exception while reading from client");
+ e.printStackTrace();
+ break;
+ }
+ } // close while loop
+ } catch (Exception e) {
+ // happens when client disconnects
+ info("General Exception");
+ e.printStackTrace();
+ info("My Client disconnected");
+ } finally {
+ isRunning = false;
+ info("Exited thread loop. Cleaning up connection");
+ cleanup();
+ }
+ }
+
+ private void processPayload(String incoming) {
+ if (!processCommand(incoming)) {
+ // if not command; send message to all clients via Server
+ server.handleMessage(this, incoming);
+ }
+
+ }
+
+ /**
+ * Attempts to see if the message is a command and process its action
+ *
+ * @param message
+ * @param sender
+ * @return true if it was a command, false otherwise
+ */
+ private boolean processCommand(String message) {
+ boolean wasCommand = false; // control var to use as the return status
+
+ // using "[cmd]" as a temporary trigger until we update how the data is passed
+ // over the socket
+ if (message.startsWith(Constants.COMMAND_TRIGGER)) {
+ // expected format will be csv for now to keep it simple
+ String[] commandData = message.split(",");
+ if (commandData.length >= 2) {
+
+ // index 0 is the trigger word
+ // index 1 is the command
+ final String command = commandData[1].trim();
+ System.out.println(TextFX.colorize("Checking command: " + command, Color.YELLOW));
+ // index N are the data from the command
+ // Note: not all commands require data, some are simply actions/triggers to
+ // process like quit
+ switch (command) {
+ case "quit":
+ case "disconnect":
+ case "logout":
+ case "logoff":
+ server.handleDisconnect(this);
+ wasCommand = true;
+ break;
+ case "reverse":
+ // ignore the first two indexes (trigger, command)
+ String relevantText = String.join(" ", Arrays.copyOfRange(commandData, 2, commandData.length));
+ server.handleReverseText(this, relevantText);
+ wasCommand = true;
+ break;
+
+ // date 10/21/25 - UCID - rk975
+ // Steps taken. Matched "flip" command in switch.
+ // Invoked server’s handleFlipCommand() method.
+ // Passed current client thread context.
+ // Set command flag to true.
+ case "flip":
+ server.handleFlipCommand(this);
+ wasCommand = true;
+ break;
+
+ // date 10/21/25 - UCID - rk975
+ // Added /pm command handling logic.
+ // Steps:
+ // 1. Parse targetId and message from commandData.
+ // 2. Call server.handlePrivateMessage(sender, targetId, messageText).
+
+ case "pm":
+ if (commandData.length >= 4) {
+ try {
+ long targetId = Long.parseLong(commandData[2].trim());
+ String messageText = String.join(" ", Arrays.copyOfRange(commandData, 3, commandData.length));
+ server.handlePrivateMessage(this, targetId, messageText);
+ } catch (NumberFormatException e) {
+ sendToClient("Server: Invalid target user ID.");
+ }
+ } else {
+ sendToClient("Server: Usage - /pm ");
+ }
+ wasCommand = true;
+ break;
+ // date 10/21/25 - UCID - rk975
+ // Added /shuffle command handling logic.
+ // Steps:
+ // 1. Extract text portion after command.
+ // 2. Call server.handleShuffleMessage(sender, text)
+ // added more cases/breaks as needed for other commands
+
+ case "shuffle":
+ if (commandData.length >= 3) {
+ String shuffleText = String.join(" ", Arrays.copyOfRange(commandData, 2, commandData.length));
+ server.handleShuffleMessage(this, shuffleText);
+ } else {
+ sendToClient("Server: Usage - /shuffle ");
+ }
+ wasCommand = true;
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ }
+ return wasCommand;
+ }
+
+ private void cleanup() {
+ info("ServerThread cleanup() start");
+ try {
+ // close server-side end of connection
+ client.close();
+ info("Closed Server-side Socket");
+ } catch (IOException e) {
+ info("Client already closed");
+ }
+ info("ServerThread cleanup() end");
+ }
+}
\ No newline at end of file
diff --git a/M4/Part3HW/TextFX.java b/M4/Part3HW/TextFX.java
new file mode 100644
index 0000000..5d85df3
--- /dev/null
+++ b/M4/Part3HW/TextFX.java
@@ -0,0 +1,72 @@
+package M4.Part3HW;
+
+/**
+ * Utility to attempt to provide colored text in the terminal.
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+public abstract class TextFX {
+
+ /**
+ * TextFX.Color list of available colors
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+ public enum Color {
+ BLACK("\033[0;30m"),
+ RED("\033[0;31m"),
+ GREEN("\033[0;32m"),
+ YELLOW("\033[0;33m"),
+ BLUE("\033[0;34m"),
+ PURPLE("\033[0;35m"),
+ CYAN("\033[0;36m"),
+ WHITE("\033[0;37m");
+
+ private final String code;
+
+ Color(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+ }
+
+ public static final String RESET = "\033[0m";
+
+ /**
+ * Generates a String with the original message wrapped in the ASCII of the
+ * color and RESET
+ *
+ *
+ * Note: May not work for all terminals
+ *
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ *
+ * @param text Input text to colorize
+ * @param color Enum of Color choice from TextFX.Color
+ * @return wrapped String
+ */
+ public static String colorize(String text, Color color) {
+ StringBuilder builder = new StringBuilder();
+ builder.append(color.getCode());
+ builder.append(text);
+ builder.append(RESET);
+ return builder.toString();
+ }
+
+ public static void main(String[] args) {
+ // Example usage:
+ System.out.println(TextFX.colorize("Hello, world!", Color.RED));
+ System.out.println(TextFX.colorize("This is some blue text.", Color.BLUE));
+ System.out.println(TextFX.colorize("And this is green!", Color.GREEN));
+ }
+}
\ No newline at end of file
diff --git a/M5/Part4/Client.java b/M5/Part4/Client.java
new file mode 100644
index 0000000..d435623
--- /dev/null
+++ b/M5/Part4/Client.java
@@ -0,0 +1,275 @@
+
+package M5.Part4;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.Scanner;
+import java.util.concurrent.CompletableFuture;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import M5.Part4.TextFX.Color;
+
+/**
+ * Demoing bi-directional communication between client and server in a
+ * multi-client scenario
+ */
+public enum Client {
+ INSTANCE;
+
+ private Socket server = null;
+ private ObjectOutputStream out = null;
+ private ObjectInputStream in = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})");
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})");
+ private volatile boolean isRunning = true; // volatile for thread-safe visibility
+
+ // needs to be private now that the enum logic is handling this
+ private Client() {
+ System.out.println("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these check the client's end of the socket connect; therefore they
+ // don't really help determine if the server had a problem
+ // and is just for lesson's sake
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+ }
+
+ /**
+ * Takes an IP address and a port to attempt a socket connection to a server.
+ *
+ * @param address
+ * @param port
+ * @return true if connection was successful
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // channel to send to server
+ out = new ObjectOutputStream(server.getOutputStream());
+ // channel to listen to server
+ in = new ObjectInputStream(server.getInputStream());
+ System.out.println("Client connected");
+ // Use CompletableFuture to run listenToServer() in a separate thread
+ CompletableFuture.runAsync(this::listenToServer);
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Check if the string contains the connect command
+ * followed by an IP address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text
+ * @return true if the text is a valid connection command
+ */
+ private boolean isConnection(String text) {
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Controller for handling various text commands.
+ *
+ * Add more here as needed
+ *
+ *
+ * @param text
+ * @return true if the text was a command or triggered a command
+ * @throws IOException
+ */
+ private boolean processClientCommand(String text) throws IOException {
+ boolean wasCommand = false;
+ if (isConnection(text)) {
+ // replaces multiple spaces with a single space
+ // splits on the space after connect (gives us host and port)
+ // splits on : to get host as index 0 and port as index 1
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ wasCommand = true;
+ } else if ("/quit".equalsIgnoreCase(text)) {
+ close();
+ wasCommand = true;
+ } else if ("/disconnect".equalsIgnoreCase(text)) {
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, Command.DISCONNECT.command };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ } else if (text.startsWith("/reverse")) {
+ text = text.replace("/reverse", "").trim();
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, Command.REVERSE.command, text };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ } else if (text.startsWith("/createroom")) {
+ text = text.replace("/createroom", "").trim();
+ if (text == null || text.length() == 0) {
+ System.out.println(TextFX.colorize("This command requires a room name as an argument", Color.RED));
+ return true;
+ }
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, Command.CREATE_ROOM.command, text };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ } else if (text.startsWith("/joinroom")) {
+ text = text.replace("/joinroom", "").trim();
+ if (text == null || text.length() == 0) {
+ System.out.println(TextFX.colorize("This command requires a room name as an argument", Color.RED));
+ return true;
+ }
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, Command.JOIN_ROOM.command, text };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ } else if (text.startsWith("/leave")) {
+ // Note: Accounts for /leave and /leaveroom variants (or anything beginning with
+ // /leave)
+ // index 0 = trigger, index 1 = command, index N = command data
+ String[] commandData = { Constants.COMMAND_TRIGGER, Command.LEAVE_ROOM.command };
+ sendToServer(String.join(",", commandData));
+ wasCommand = true;
+ }
+ return wasCommand;
+ }
+
+ public void start() throws IOException {
+ System.out.println("Client starting");
+
+ // Use CompletableFuture to run listenToInput() in a separate thread
+ CompletableFuture inputFuture = CompletableFuture.runAsync(this::listenToInput);
+
+ // Wait for inputFuture to complete to ensure proper termination
+ inputFuture.join();
+ }
+
+ /**
+ * Listens for messages from the server
+ */
+ private void listenToServer() {
+ try {
+ while (isRunning && isConnected()) {
+ String fromServer = (String) in.readObject(); // blocking read
+ if (fromServer != null) {
+ System.out.println(TextFX.colorize(fromServer, Color.BLUE));
+ } else {
+ System.out.println("Server disconnected");
+ break;
+ }
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (isRunning) {
+ System.out.println("Connection dropped");
+ e.printStackTrace();
+ }
+ } finally {
+ closeServerConnection();
+ }
+ System.out.println("listenToServer thread stopped");
+ }
+
+ /**
+ * Listens for keyboard input from the user
+ */
+ private void listenToInput() {
+ try (Scanner si = new Scanner(System.in)) {
+ System.out.println("Waiting for input"); // moved here to avoid console spam
+ while (isRunning) { // Run until isRunning is false
+ String userInput = si.nextLine();
+ if (!processClientCommand(userInput)) {
+ sendToServer(userInput);
+ }
+ }
+ } catch (IOException ioException) {
+ System.out.println("Error in listentToInput()");
+ ioException.printStackTrace();
+ }
+ System.out.println("listenToInput thread stopped");
+ }
+
+ private void sendToServer(String message) throws IOException {
+ if (isConnected()) {
+ out.writeObject(message);
+ out.flush(); // good practice to ensure data is written out immediately
+ } else {
+ System.out.println(
+ "Not connected to server (hint: type `/connect host:port` without the quotes and replace host/port with the necessary info)");
+ }
+ }
+
+ /**
+ * Closes the client connection and associated resources
+ */
+ private void close() {
+ isRunning = false;
+ closeServerConnection();
+ System.out.println("Client terminated");
+ // System.exit(0); // Terminate the application
+ }
+
+ /**
+ * Closes the server connection and associated resources
+ */
+ private void closeServerConnection() {
+ try {
+ if (out != null) {
+ System.out.println("Closing output stream");
+ out.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (in != null) {
+ System.out.println("Closing input stream");
+ in.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (server != null) {
+ System.out.println("Closing connection");
+ server.close();
+ System.out.println("Closed socket");
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = Client.INSTANCE;
+ try {
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/M5/Part4/Command.java b/M5/Part4/Command.java
new file mode 100644
index 0000000..1cd3969
--- /dev/null
+++ b/M5/Part4/Command.java
@@ -0,0 +1,30 @@
+package M5.Part4;
+
+import java.util.HashMap;
+
+public enum Command {
+ QUIT("quit"),
+ DISCONNECT("disconnect"),
+ LOGOUT("logout"),
+ LOGOFF("logoff"),
+ REVERSE("reverse"),
+ CREATE_ROOM("createroom"),
+ LEAVE_ROOM("leaveroom"),
+ JOIN_ROOM("joinroom");
+
+ private static final HashMap BY_COMMAND = new HashMap<>();
+ static {
+ for (Command e : values()) {
+ BY_COMMAND.put(e.command, e);
+ }
+ }
+ public final String command;
+
+ private Command(String command) {
+ this.command = command;
+ }
+
+ public static Command stringToCommand(String command) {
+ return BY_COMMAND.get(command);
+ }
+}
\ No newline at end of file
diff --git a/M5/Part4/Constants.java b/M5/Part4/Constants.java
new file mode 100644
index 0000000..d33bbbd
--- /dev/null
+++ b/M5/Part4/Constants.java
@@ -0,0 +1,6 @@
+package M5.Part4;
+
+public abstract class Constants {
+ final public static String COMMAND_TRIGGER = "[cmd]";
+ final public static String SINGLE_SPACE = " ";
+}
diff --git a/M5/Part4/CustomIT114Exception.java b/M5/Part4/CustomIT114Exception.java
new file mode 100644
index 0000000..ff7e38d
--- /dev/null
+++ b/M5/Part4/CustomIT114Exception.java
@@ -0,0 +1,11 @@
+package M5.Part4;
+
+public abstract class CustomIT114Exception extends Exception {
+ public CustomIT114Exception(String message) {
+ super(message);
+ }
+
+ public CustomIT114Exception(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/M5/Part4/DuplicateRoomException.java b/M5/Part4/DuplicateRoomException.java
new file mode 100644
index 0000000..986eff2
--- /dev/null
+++ b/M5/Part4/DuplicateRoomException.java
@@ -0,0 +1,12 @@
+package M5.Part4;
+
+public class DuplicateRoomException extends CustomIT114Exception {
+ public DuplicateRoomException(String message) {
+ super(message);
+ }
+
+ public DuplicateRoomException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/M5/Part4/Room.java b/M5/Part4/Room.java
new file mode 100644
index 0000000..f277c11
--- /dev/null
+++ b/M5/Part4/Room.java
@@ -0,0 +1,223 @@
+package M5.Part4;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import M5.Part4.TextFX.Color;
+
+public class Room {
+ private final String name;// unique name of the Room
+ private volatile boolean isRunning = false;
+ private final ConcurrentHashMap clientsInRoom = new ConcurrentHashMap();
+
+ public final static String LOBBY = "lobby";
+
+ private void info(String message) {
+ System.out.println(TextFX.colorize(String.format("Room[%s]: %s", name, message), Color.PURPLE));
+ }
+
+ public Room(String name) {
+ this.name = name;
+ isRunning = true;
+ info("Created");
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ protected synchronized void addClient(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ if (clientsInRoom.containsKey(client.getClientId())) {
+ info("Attempting to add a client that already exists in the room");
+ return;
+ }
+ clientsInRoom.put(client.getClientId(), client);
+ client.setCurrentRoom(this);
+ // notify clients of someone joining
+ // relay(null, String.format("User[%s] joined the room", client.getClientId()));
+ joinStatusRelay(client, true);
+
+ }
+
+ protected synchronized void removeClient(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ if (!clientsInRoom.containsKey(client.getClientId())) {
+ info("Attempting to remove a client that doesn't exist in the room");
+ return;
+ }
+ ServerThread removedClient = clientsInRoom.get(client.getClientId());
+ if (removedClient != null) {
+ // notify clients of someone joining
+ joinStatusRelay(removedClient, false);
+ clientsInRoom.remove(client.getClientId());
+ autoCleanup();
+ }
+ }
+
+ private void joinStatusRelay(ServerThread client, boolean didJoin) {
+ clientsInRoom.values().removeIf(serverThread -> {
+ String formattedMessage = String.format("Room[%s] %s %s the room",
+ getName(),
+ client.getClientId() == serverThread.getClientId() ? "You"
+ : String.format("User[%s]", client.getClientId()),
+ didJoin ? "joined" : "left");
+ boolean failedToSend = !serverThread.sendToClient(formattedMessage);
+ if (failedToSend) {
+ System.out.println(
+ String.format("Removing disconnected client[%s] from list", serverThread.getClientId()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Sends a basic String message from the sender to all connectedClients
+ * Internally calls processCommand and evaluates as necessary.
+ * Note: Clients that fail to receive a message get removed from
+ * connectedClients.
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ protected synchronized void relay(ServerThread sender, String message) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+
+ // Note: any desired changes to the message must be done before this line
+ String senderString = sender == null ? String.format("Room[%s]", getName())
+ : String.format("User[%s]", sender.getClientId());
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't be changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+
+ // loop over clients and send out the message; remove client if message failed
+ // to be sent
+ // Note: this uses a lambda expression for each item in the values() collection,
+ // it's one way we can safely remove items during iteration
+ info(String.format("sending message to %s recipients: %s", clientsInRoom.size(), formattedMessage));
+
+ clientsInRoom.values().removeIf(serverThread -> {
+ boolean failedToSend = !serverThread.sendToClient(formattedMessage);
+ if (failedToSend) {
+ System.out.println(
+ String.format("Removing disconnected client[%s] from list", serverThread.getClientId()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Takes a ServerThread and removes them from the Server
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param client
+ */
+ private synchronized void disconnect(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ ServerThread disconnectingServerThread = clientsInRoom.remove(client.getClientId());
+ if (disconnectingServerThread != null) {
+ disconnectingServerThread.disconnect();
+ relay(null, "User[" + disconnectingServerThread.getClientId() + "] disconnected");
+ }
+ autoCleanup();
+ }
+
+ protected synchronized void disconnectAll() {
+ info("Disconnect All triggered");
+ if (!isRunning) {
+ return;
+ }
+ clientsInRoom.values().removeIf(client -> {
+ disconnect(client);
+ return true;
+ });
+ info("Disconnect All finished");
+ }
+
+ /**
+ * Attempts to close the room to free up resources if it's empty
+ */
+ private void autoCleanup() {
+ if (!Room.LOBBY.equalsIgnoreCase(name) && clientsInRoom.isEmpty()) {
+ close();
+ }
+ }
+
+ public void close() {
+ // attempt to gracefully close and migrate clients
+ if (!clientsInRoom.isEmpty()) {
+ relay(null, "Room is shutting down, migrating to lobby");
+ info(String.format("migrating %s clients", clientsInRoom.size()));
+ clientsInRoom.values().removeIf(client -> {
+ try {
+ Server.INSTANCE.joinRoom(Room.LOBBY, client);
+ } catch (RoomNotFoundException e) {
+ e.printStackTrace();
+ // TODO, fill in, this shouldn't happen though
+ }
+ return true;
+ });
+ }
+ Server.INSTANCE.removeRoom(this);
+ isRunning = false;
+ clientsInRoom.clear();
+ info(String.format("closed"));
+ }
+
+ // start handle methods
+ public void handleCreateRoom(ServerThread sender, String roomName) {
+ try {
+ Server.INSTANCE.createRoom(roomName);
+ Server.INSTANCE.joinRoom(roomName, sender);
+ } catch (RoomNotFoundException e) {
+ info("Room wasn't found (this shouldn't happen)");
+ e.printStackTrace();
+ } catch (DuplicateRoomException e) {
+ sender.sendToClient(String.format("Room %s already exists", roomName));
+ }
+ }
+
+ public void handleJoinRoom(ServerThread sender, String roomName) {
+ try {
+ Server.INSTANCE.joinRoom(roomName, sender);
+ } catch (RoomNotFoundException e) {
+ sender.sendToClient(String.format("Room %s doesn't exist", roomName));
+ }
+ }
+
+ /**
+ * Expose access to the disconnect action
+ *
+ * @param serverThread
+ */
+ protected synchronized void handleDisconnect(ServerThread sender) {
+ disconnect(sender);
+ }
+
+ protected synchronized void handleReverseText(ServerThread sender, String text) {
+ StringBuilder sb = new StringBuilder(text);
+ sb.reverse();
+ String rev = sb.toString();
+ relay(sender, rev);
+ }
+
+ protected synchronized void handleMessage(ServerThread sender, String text) {
+ relay(sender, text);
+ }
+ // end handle methods
+}
diff --git a/M5/Part4/RoomNotFoundException.java b/M5/Part4/RoomNotFoundException.java
new file mode 100644
index 0000000..c95434c
--- /dev/null
+++ b/M5/Part4/RoomNotFoundException.java
@@ -0,0 +1,13 @@
+package M5.Part4;
+
+public class RoomNotFoundException extends CustomIT114Exception {
+
+ public RoomNotFoundException(String message) {
+ super(message);
+ }
+
+ public RoomNotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/M5/Part4/Server.java b/M5/Part4/Server.java
new file mode 100644
index 0000000..76f9eb3
--- /dev/null
+++ b/M5/Part4/Server.java
@@ -0,0 +1,194 @@
+package M5.Part4;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.ConcurrentHashMap;
+
+import M5.Part4.TextFX.Color;
+
+public enum Server {
+ INSTANCE; // Singleton instance
+
+ private int port = 3000;
+ // connected clients
+ // Use ConcurrentHashMap for thread-safe client management
+ // The key is the unique Room name and the Room is the instance
+ private final ConcurrentHashMap rooms = new ConcurrentHashMap<>();
+ private boolean isRunning = true;
+
+ private void info(String message) {
+ System.out.println(TextFX.colorize(String.format("Server: %s", message), Color.YELLOW));
+ }
+
+ private Server() {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ info("JVM is shutting down. Perform cleanup tasks.");
+ shutdown();
+ }));
+ }
+
+ /**
+ * Gracefully disconnect clients
+ */
+ private void shutdown() {
+ try {
+ // chose removeIf over forEach to avoid potential
+ // ConcurrentModificationException
+ // since empty rooms tell the server to remove themselves
+ rooms.values().removeIf(room -> {
+ room.disconnectAll();
+ return true;
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void start(int port) {
+ this.port = port;
+ // server listening
+ info("Listening on port " + this.port);
+ // Simplified client connection loop
+ try (ServerSocket serverSocket = new ServerSocket(port)) {
+ createRoom(Room.LOBBY);// create the first room (lobby)
+ while (isRunning) {
+ info("Waiting for next client");
+ Socket incomingClient = serverSocket.accept(); // blocking action, waits for a client connection
+ info("Client connected");
+ // wrap socket in a ServerThread, pass a callback to notify the Server when
+ // they're initialized
+ ServerThread serverThread = new ServerThread(incomingClient, this::onServerThreadInitialized);
+ // start the thread (typically an external entity manages the lifecycle and we
+ // don't have the thread start itself)
+ serverThread.start();
+ // Note: We don't yet add the ServerThread reference to our connectedClients map
+ }
+ } catch (DuplicateRoomException e) {
+ System.err.println(TextFX.colorize("Lobby already exists (this shouldn't happen)", Color.RED));
+ } catch (IOException e) {
+ System.err.println(TextFX.colorize("Error accepting connection", Color.RED));
+ e.printStackTrace();
+ } finally {
+ info("Closing server socket");
+ }
+ }
+
+ /**
+ * Callback passed to ServerThread to inform Server they're ready to receive
+ * data
+ *
+ * @param serverThread
+ */
+ private void onServerThreadInitialized(ServerThread serverThread) {
+ // add initialized client to the lobby
+ info(String.format("*User[%s] initialized*", serverThread.getClientId()));
+ try {
+ joinRoom(Room.LOBBY, serverThread);
+ info(String.format("*User[%s] added to Lobby*", serverThread.getClientId()));
+ } catch (RoomNotFoundException e) {
+ info(String.format("*Error adding User[%s] to Lobby*", serverThread.getClientId()));
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Attempts to create a new Room and add it to the tracked rooms collection
+ *
+ * @param name Unique name of the room
+ * @return true if it was created and false if it wasn't
+ * @throws DuplicateRoomException
+ */
+ protected void createRoom(String name) throws DuplicateRoomException {
+ final String nameCheck = name.toLowerCase();
+ if (rooms.containsKey(nameCheck)) {
+ throw new DuplicateRoomException(String.format("Room %s already exists", name));
+ }
+ Room room = new Room(name);
+ rooms.put(nameCheck, room);
+ info(String.format("Created new Room %s", name));
+ }
+
+ /**
+ * Attempts to move a client (ServerThread) between rooms
+ *
+ * @param name the target room to join
+ * @param client the client moving
+ * @throws RoomNotFoundException
+ *
+ */
+ protected void joinRoom(String name, ServerThread client) throws RoomNotFoundException {
+ final String nameCheck = name.toLowerCase();
+ if (!rooms.containsKey(nameCheck)) {
+ throw new RoomNotFoundException(String.format("Room %s wasn't found", name));
+ }
+ Room currentRoom = client.getCurrentRoom();
+ if (currentRoom != null) {
+ info("Removing client from previous Room " + currentRoom.getName());
+ currentRoom.removeClient(client);
+ }
+ Room next = rooms.get(nameCheck);
+ next.addClient(client);
+ }
+
+ protected void removeRoom(Room room) {
+ rooms.remove(room.getName().toLowerCase());
+ info(String.format("Removed room %s", room.getName()));
+ }
+
+ /**
+ *
+ *
+ * Note: Not a common use-case; just updated for example sake.
+ *
+ * Relays the message from the sender to all rooms
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ private synchronized void relayToAllRooms(ServerThread sender, String message) {
+ // Note: any desired changes to the message must be done before this line
+ String senderString = sender == null ? "Server" : String.format("User[%s]", sender.getClientId());
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+ // end temp identifier
+
+ // loop over Rooms and send out the message
+ // Note: this uses a lambda expression for each item in the values() collection
+
+ rooms.values().forEach(room -> {
+ room.relay(sender, formattedMessage);
+ });
+ }
+
+ /**
+ * Used to send a message to all Rooms.
+ * This is just an example and we likely won't be using this
+ *
+ * @param sender
+ * @param message
+ */
+ public synchronized void broadcastMessageToAllRooms(ServerThread sender, String message) {
+ relayToAllRooms(sender, message);
+ }
+
+ public static void main(String[] args) {
+ System.out.println("Server Starting");
+ Server server = Server.INSTANCE;
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can ignore, will either be index out of bounds or type mismatch
+ // will default to the defined value prior to the try/catch
+ }
+ server.start(port);
+ System.out.println("Server Stopped");
+ }
+
+}
diff --git a/M5/Part4/ServerThread.java b/M5/Part4/ServerThread.java
new file mode 100644
index 0000000..1e30541
--- /dev/null
+++ b/M5/Part4/ServerThread.java
@@ -0,0 +1,266 @@
+package M5.Part4;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.function.Consumer;
+import M5.Part4.TextFX.Color;
+
+/**
+ * A server-side representation of a single client
+ */
+public class ServerThread extends Thread {
+ private Socket client; // communication directly to "my" client
+ private boolean isRunning = false; // control variable to stop this thread
+ private ObjectOutputStream out; // exposed here for send()
+
+ private long clientId;
+ private Consumer onInitializationComplete; // callback to inform when this object is ready
+ private Room currentRoom;
+
+ /**
+ * A wrapper method so we don't need to keep typing out the long/complex sysout
+ * line inside
+ *
+ * @param message
+ */
+ private void info(String message) {
+ System.out.println(String.format("Thread[%s]: %s", this.getClientId(), message));
+ }
+
+ /**
+ * Returns the status of this ServerThread
+ *
+ * @return
+ */
+ public boolean isRunning() {
+ return isRunning;
+ }
+
+ /**
+ * Wraps the Socket connection and takes a Server reference and a callback
+ *
+ * @param myClient
+ * @param server
+ * @param onInitializationComplete method to inform listener that this object is
+ * ready
+ */
+ protected ServerThread(Socket myClient, Consumer onInitializationComplete) {
+ Objects.requireNonNull(myClient, "Client socket cannot be null");
+ Objects.requireNonNull(onInitializationComplete, "callback cannot be null");
+ info("ServerThread created");
+ // get communication channels to single client
+ this.client = myClient;
+ this.clientId = this.threadId(); // An id associated with the thread instance, used as a temporary identifier
+ this.onInitializationComplete = onInitializationComplete;
+
+ }
+
+ public long getClientId() {
+ // Note: We return clientId instead of threadId as we'll change this identifier
+ // in the future
+ return this.clientId;
+ }
+
+ /**
+ * Returns the current Room associated with this ServerThread
+ *
+ * @return
+ */
+ protected Room getCurrentRoom() {
+ return this.currentRoom;
+ }
+
+ /**
+ * Allows the setting of a non-null Room reference to this ServerThread
+ *
+ * @param room
+ */
+ protected void setCurrentRoom(Room room) {
+ if (room == null) {
+ throw new NullPointerException("Room argument can't be null");
+ }
+ if (room == currentRoom) {
+ System.out.println(
+ String.format("ServerThread set to the same room [%s], was this intentional?", room.getName()));
+ }
+ currentRoom = room;
+ }
+
+ /**
+ * One of the two ways to get this to exit the listen loop
+ */
+ protected void disconnect() {
+ if (!isRunning) {
+ // prevent multiple triggers if this gets called consecutively
+ return;
+ }
+ info("Thread being disconnected by server");
+ isRunning = false;
+ this.interrupt(); // breaks out of blocking read in the run() method
+ cleanup(); // good practice to ensure data is written out immediately
+ }
+
+ /**
+ * Sends the message over the socket
+ *
+ * @param message
+ * @return true if no errors were encountered
+ */
+ protected boolean sendToClient(String message) {
+ if (!isRunning) {
+ return false;
+ }
+ try {
+ out.writeObject(message);
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ info("Error sending message to client (most likely disconnected)");
+ // comment this out to inspect the stack trace
+ // e.printStackTrace();
+ cleanup();
+ return false;
+ }
+ }
+
+ @Override
+ public void run() {
+ info("Thread starting");
+ try (ObjectOutputStream out = new ObjectOutputStream(client.getOutputStream());
+ ObjectInputStream in = new ObjectInputStream(client.getInputStream());) {
+ this.out = out;
+ isRunning = true;
+ onInitializationComplete.accept(this); // Notify server that initialization is complete
+ String fromClient;
+ /**
+ * isRunning is a flag to let us manage the loop exit condition
+ * fromClient (in.readObject()) is a blocking method that waits until data is
+ * received
+ * - null would likely mean a disconnect so we use a "set and check" logic to
+ * alternatively exit the loop
+ */
+ while (isRunning) {
+ try {
+ fromClient = (String) in.readObject(); // blocking method
+ if (fromClient == null) {
+ throw new IOException("Connection interrupted"); // Specific exception for a clean break
+ } else {
+ info(TextFX.colorize("Received from my client: " + fromClient, Color.CYAN));
+ processPayload(fromClient);
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (Thread.currentThread().isInterrupted()) {
+ info("Thread interrupted during read (likely from the disconnect() method)");
+ break;
+ }
+ info("IO exception while reading from client");
+ e.printStackTrace();
+ break;
+ }
+ } // close while loop
+ } catch (Exception e) {
+ // happens when client disconnects
+ info("General Exception");
+ e.printStackTrace();
+ info("My Client disconnected");
+ } finally {
+ isRunning = false;
+ info("Exited thread loop. Cleaning up connection");
+ cleanup();
+ }
+ }
+
+ private void processPayload(String incoming) {
+ Objects.requireNonNull(currentRoom, "Can't process a payload when the current room is null");
+ if (!processCommand(incoming)) {
+ // if not command; send message to all clients via Server
+ currentRoom.handleMessage(this, incoming);
+ }
+ }
+
+ /**
+ * Attempts to see if the message is a command and process its action
+ *
+ * @param message
+ * @param sender
+ * @return true if it was a command, false otherwise
+ */
+ private boolean processCommand(String message) {
+ Objects.requireNonNull(currentRoom, "Can't process a command when the current room is null");
+
+ boolean wasCommand = false; // control var to use as the return status
+
+ // using "[cmd]" as a temporary trigger until we update how the data is passed
+ // over the socket
+ if (message.startsWith(Constants.COMMAND_TRIGGER)) {
+ // expected format will be csv for now to keep it simple
+ String[] commandData = message.split(",");
+ if (commandData.length >= 2) {
+
+ // index 0 is the trigger word
+ // index 1 is the command
+
+ // part 4 added the Command enum
+ final Command command = Command.stringToCommand(commandData[1].trim());
+
+ System.out.println(TextFX.colorize("Checking command: " + command, Color.YELLOW));
+ // index N are the data from the command
+ // Note: not all commands require data, some are simply actions/triggers to
+ // process like quit
+ switch (command) {
+ case Command.QUIT:
+ case Command.DISCONNECT:
+ case Command.LOGOUT:
+ case Command.LOGOFF:
+ currentRoom.handleDisconnect(this);
+ wasCommand = true;
+ break;
+ case Command.REVERSE:
+ // ignore the first two indexes (trigger, command)
+ String relevantText = String.join(" ", Arrays.copyOfRange(commandData, 2, commandData.length));
+ currentRoom.handleReverseText(this, relevantText);
+ wasCommand = true;
+ break;
+ case Command.CREATE_ROOM:
+ currentRoom.handleCreateRoom(this, commandData[2]);
+ wasCommand = true;
+ break;
+ case Command.JOIN_ROOM:
+ currentRoom.handleJoinRoom(this, commandData[2]);
+ wasCommand = true;
+ break;
+ case Command.LEAVE_ROOM:
+ // leaving simply joins the lobby since a ServerThread must always exist in a
+ // Room
+ currentRoom.handleJoinRoom(this, Room.LOBBY);
+ wasCommand = true;
+ break;
+ // added more cases/breaks as needed for other commands
+ default:
+ break;
+ }
+ }
+
+ }
+ return wasCommand;
+ }
+
+ private void cleanup() {
+ info("ServerThread cleanup() start");
+ try {
+ // close server-side end of connection
+ client.close();
+ info("Closed Server-side Socket");
+ } catch (IOException e) {
+ info("Client already closed");
+ }
+ info("ServerThread cleanup() end");
+ }
+}
diff --git a/M5/Part4/TextFX.java b/M5/Part4/TextFX.java
new file mode 100644
index 0000000..4782701
--- /dev/null
+++ b/M5/Part4/TextFX.java
@@ -0,0 +1,72 @@
+package M5.Part4;
+
+/**
+ * Utility to attempt to provide colored text in the terminal.
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+public abstract class TextFX {
+
+ /**
+ * TextFX.Color list of available colors
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+ public enum Color {
+ BLACK("\033[0;30m"),
+ RED("\033[0;31m"),
+ GREEN("\033[0;32m"),
+ YELLOW("\033[0;33m"),
+ BLUE("\033[0;34m"),
+ PURPLE("\033[0;35m"),
+ CYAN("\033[0;36m"),
+ WHITE("\033[0;37m");
+
+ private final String code;
+
+ Color(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+ }
+
+ public static final String RESET = "\033[0m";
+
+ /**
+ * Generates a String with the original message wrapped in the ASCII of the
+ * color and RESET
+ *
+ *
+ * Note: May not work for all terminals
+ *
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ *
+ * @param text Input text to colorize
+ * @param color Enum of Color choice from TextFX.Color
+ * @return wrapped String
+ */
+ public static String colorize(String text, Color color) {
+ StringBuilder builder = new StringBuilder();
+ builder.append(color.getCode());
+ builder.append(text);
+ builder.append(RESET);
+ return builder.toString();
+ }
+
+ public static void main(String[] args) {
+ // Example usage:
+ System.out.println(TextFX.colorize("Hello, world!", Color.RED));
+ System.out.println(TextFX.colorize("This is some blue text.", Color.BLUE));
+ System.out.println(TextFX.colorize("And this is green!", Color.GREEN));
+ }
+}
diff --git a/M5/Part5/BaseServerThread.java b/M5/Part5/BaseServerThread.java
new file mode 100644
index 0000000..2f2c08c
--- /dev/null
+++ b/M5/Part5/BaseServerThread.java
@@ -0,0 +1,219 @@
+package M5.Part5;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+
+/**
+ * Base class the handles the underlying connection between Client and
+ * Server-side
+ */
+public abstract class BaseServerThread extends Thread {
+
+ protected boolean isRunning = false; // control variable to stop this thread
+ protected ObjectOutputStream out; // exposed here for send()
+ protected Socket client; // communication directly to "my" client
+ private User user = new User();
+ protected Room currentRoom;
+
+ /**
+ * Returns the current Room associated with this ServerThread
+ *
+ * @return
+ */
+ protected Room getCurrentRoom() {
+ return this.currentRoom;
+ }
+
+ /**
+ * Allows the setting of a non-null Room reference to this ServerThread
+ *
+ * @param room
+ */
+ protected void setCurrentRoom(Room room) {
+ if (room == null) {
+ throw new NullPointerException("Room argument can't be null");
+ }
+ if (room == currentRoom) {
+ System.out.println(
+ String.format("ServerThread set to the same room [%s], was this intentional?", room.getName()));
+ }
+ currentRoom = room;
+ }
+
+ /**
+ * Returns the status of this ServerThread
+ *
+ * @return
+ */
+ public boolean isRunning() {
+ return isRunning;
+ }
+
+ public void setClientId(long clientId) {
+ this.user.setClientId(clientId);
+ }
+
+ public long getClientId() {
+ // Note: We return clientId instead of threadId as we'll change this identifier
+ // in the future
+ return this.user.getClientId();
+ }
+
+ /**
+ * Sets the client name and triggers onInitialized()
+ *
+ * @param clientName
+ */
+ protected void setClientName(String clientName) {
+ this.user.setClientName(clientName);
+ onInitialized();
+ }
+
+ public String getClientName() {
+ return this.user.getClientName();
+ }
+
+ public String getDisplayName() {
+ return this.user.getDisplayName();
+ }
+
+ /**
+ * A wrapper method so we don't need to keep typing out the long/complex sysout
+ * line inside
+ *
+ * @param message
+ */
+ protected abstract void info(String message);
+
+ /**
+ * Triggered when object is fully initialized
+ */
+ protected abstract void onInitialized();
+
+ /**
+ * Receives a Payload and passes data to proper handler
+ *
+ * @param payload
+ */
+ protected abstract void processPayload(Payload payload);
+
+ /**
+ * Sends the payload over the socket
+ *
+ * @param payload
+ * @return true if no errors were encountered
+ */
+ protected boolean sendToClient(Payload payload) {
+ if (!isRunning) {
+ return true;
+ }
+ try {
+ info("Sending to client: " + payload);
+ out.writeObject(payload);
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ info("Error sending message to client (most likely disconnected)");
+ // comment this out to inspect the stack trace
+ // e.printStackTrace();
+ cleanup();
+ return false;
+ }
+ }
+
+ /**
+ * Terminates the server-side of the connection
+ */
+ protected void disconnect() {
+ if (!isRunning) {
+ // prevent multiple triggers if this gets called consecutively
+ return;
+ }
+ info("Thread being disconnected by server");
+ isRunning = false;
+ this.interrupt(); // breaks out of blocking read in the run() method
+ cleanup(); // good practice to ensure data is written out immediately
+ }
+
+ @Override
+ public void run() {
+ info("Thread starting");
+ try (ObjectOutputStream out = new ObjectOutputStream(client.getOutputStream());
+ ObjectInputStream in = new ObjectInputStream(client.getInputStream());) {
+ this.out = out;
+ isRunning = true;
+ new java.util.Timer().schedule(new java.util.TimerTask() {
+ @Override
+ public void run() {
+ if (getClientName() == null || getClientName().isBlank()) {
+ info("Client name not received. Disconnecting");
+ disconnect();
+ }
+ }
+ }, 3000);
+ Payload fromClient;
+ /**
+ * isRunning is a flag to let us manage the loop exit condition
+ * fromClient (in.readObject()) is a blocking method that waits until data is
+ * received
+ * - null would likely mean a disconnect so we use a "set and check" logic to
+ * alternatively exit the loop
+ */
+ while (isRunning) {
+ try {
+ fromClient = (Payload) in.readObject(); // blocking method
+ if (fromClient != null) {
+ info("Received from my client: " + fromClient);
+ processPayload(fromClient);
+ } else {
+ throw new IOException("Connection interrupted"); // Specific exception for a clean break
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (Thread.currentThread().isInterrupted()) {
+ info("Thread interrupted during read (likely from the disconnect() method)");
+ break;
+ }
+ info("IO exception while reading from client");
+ e.printStackTrace();
+ break;
+ }
+ } // close while loop
+ } catch (Exception e) {
+ // happens when client disconnects
+ info("General Exception");
+ e.printStackTrace();
+ info("My Client disconnected");
+ } finally {
+ if (currentRoom != null) {
+ currentRoom.handleDisconnect(this);
+ }
+ isRunning = false;
+ info("Exited thread loop. Cleaning up connection");
+ cleanup();
+ }
+ }
+
+ /**
+ * Cleanup method to close the connection and reset the user object
+ */
+ protected void cleanup() {
+ info("ServerThread cleanup() start");
+ try {
+ // close server-side end of connection
+ currentRoom = null;
+ out.close();
+ client.close();
+ user.reset();
+ info("Closed Server-side Socket");
+ } catch (IOException e) {
+ info("Client already closed");
+ }
+
+ info("ServerThread cleanup() end");
+ }
+}
diff --git a/M5/Part5/Client.java b/M5/Part5/Client.java
new file mode 100644
index 0000000..bb912b2
--- /dev/null
+++ b/M5/Part5/Client.java
@@ -0,0 +1,495 @@
+
+package M5.Part5;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.Scanner;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import M5.Part5.TextFX.Color;
+
+/**
+ * Demoing bi-directional communication between client and server in a
+ * multi-client scenario
+ */
+public enum Client {
+ INSTANCE;
+
+ private Socket server = null;
+ private ObjectOutputStream out = null;
+ private ObjectInputStream in = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})");
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})");
+ private volatile boolean isRunning = true; // volatile for thread-safe visibility
+ private final ConcurrentHashMap knownClients = new ConcurrentHashMap();
+ private User myUser = new User();
+
+ private void error(String message) {
+ System.out.println(TextFX.colorize(String.format("%s", message), Color.RED));
+ }
+
+ // needs to be private now that the enum logic is handling this
+ private Client() {
+ System.out.println("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these check the client's end of the socket connect; therefore they
+ // don't really help determine if the server had a problem
+ // and is just for lesson's sake
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+ }
+
+ /**
+ * Takes an IP address and a port to attempt a socket connection to a server.
+ *
+ * @param address
+ * @param port
+ * @return true if connection was successful
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // channel to send to server
+ out = new ObjectOutputStream(server.getOutputStream());
+ // channel to listen to server
+ in = new ObjectInputStream(server.getInputStream());
+ System.out.println("Client connected");
+ // Use CompletableFuture to run listenToServer() in a separate thread
+ CompletableFuture.runAsync(this::listenToServer);
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Check if the string contains the connect command
+ * followed by an IP address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text
+ * @return true if the text is a valid connection command
+ */
+ private boolean isConnection(String text) {
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Controller for handling various text commands.
+ *
+ * Add more here as needed
+ *
+ *
+ * @param text
+ * @return true if the text was a command or triggered a command
+ * @throws IOException
+ */
+ private boolean processClientCommand(String text) throws IOException {
+ boolean wasCommand = false;
+ if (text.startsWith(Constants.COMMAND_TRIGGER)) {
+ text = text.substring(1); // remove the /
+ // System.out.println("Checking command: " + text);
+ if (isConnection("/" + text)) {
+ if (myUser.getClientName() == null || myUser.getClientName().isEmpty()) {
+ System.out.println(
+ TextFX.colorize("Please set your name via /name before connecting", Color.RED));
+ return true;
+ }
+ // replaces multiple spaces with a single space
+ // splits on the space after connect (gives us host and port)
+ // splits on : to get host as index 0 and port as index 1
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ sendClientName(myUser.getClientName());// sync follow-up data (handshake)
+ wasCommand = true;
+ } else if (text.startsWith(Command.NAME.command)) {
+ text = text.replace(Command.NAME.command, "").trim();
+ if (text == null || text.length() == 0) {
+ System.out.println(TextFX.colorize("This command requires a name as an argument", Color.RED));
+ return true;
+ }
+ myUser.setClientName(text);// temporary until we get a response from the server
+ System.out.println(TextFX.colorize(String.format("Name set to %s", myUser.getClientName()),
+ Color.YELLOW));
+ wasCommand = true;
+ } else if (text.equalsIgnoreCase(Command.LIST_USERS.command)) {
+ System.out.println(TextFX.colorize("Known clients:", Color.CYAN));
+ knownClients.forEach((key, value) -> {
+ System.out.println(TextFX.colorize(String.format("%s%s", value.getDisplayName(),
+ key == myUser.getClientId() ? " (you)" : ""), Color.CYAN));
+ });
+ wasCommand = true;
+ } else if (Command.QUIT.command.equalsIgnoreCase(text)) {
+ close();
+ wasCommand = true;
+ } else if (Command.DISCONNECT.command.equalsIgnoreCase(text)) {
+ sendDisconnect();
+ wasCommand = true;
+ } else if (text.startsWith(Command.REVERSE.command)) {
+ text = text.replace(Command.REVERSE.command, "").trim();
+ sendReverse(text);
+ wasCommand = true;
+ } else if (text.startsWith(Command.CREATE_ROOM.command)) {
+ text = text.replace(Command.CREATE_ROOM.command, "").trim();
+ if (text == null || text.length() == 0) {
+ System.out.println(TextFX.colorize("This command requires a room name as an argument", Color.RED));
+ return true;
+ }
+ sendRoomAction(text, RoomAction.CREATE);
+ wasCommand = true;
+ } else if (text.startsWith(Command.JOIN_ROOM.command)) {
+ text = text.replace(Command.JOIN_ROOM.command, "").trim();
+ if (text == null || text.length() == 0) {
+ System.out.println(TextFX.colorize("This command requires a room name as an argument", Color.RED));
+ return true;
+ }
+ sendRoomAction(text, RoomAction.JOIN);
+ wasCommand = true;
+ } else if (text.startsWith(Command.LEAVE_ROOM.command) || text.startsWith("leave")) {
+ // Note: Accounts for /leave and /leaveroom variants (or anything beginning with
+ // /leave)
+ sendRoomAction(text, RoomAction.LEAVE);
+ wasCommand = true;
+ }
+ }
+ return wasCommand;
+ }
+
+ // Start Send*() methods
+
+ /**
+ * Sends a room action to the server
+ *
+ * @param roomName
+ * @param roomAction (join, leave, create)
+ * @throws IOException
+ */
+ private void sendRoomAction(String roomName, RoomAction roomAction) throws IOException {
+ Payload payload = new Payload();
+ payload.setMessage(roomName);
+ switch (roomAction) {
+ case RoomAction.CREATE:
+ payload.setPayloadType(PayloadType.ROOM_CREATE);
+ break;
+ case RoomAction.JOIN:
+ payload.setPayloadType(PayloadType.ROOM_JOIN);
+ break;
+ case RoomAction.LEAVE:
+ payload.setPayloadType(PayloadType.ROOM_LEAVE);
+ break;
+ default:
+ System.out.println(TextFX.colorize("Invalid room action", Color.RED));
+ break;
+ }
+ sendToServer(payload);
+ }
+
+ /**
+ * Sends a reverse message action to the server
+ *
+ * @param message
+ * @throws IOException
+ */
+ private void sendReverse(String message) throws IOException {
+ Payload payload = new Payload();
+ payload.setMessage(message);
+ payload.setPayloadType(PayloadType.REVERSE);
+ sendToServer(payload);
+
+ }
+
+ /**
+ * Sends a disconnect action to the server
+ *
+ * @throws IOException
+ */
+ private void sendDisconnect() throws IOException {
+ Payload payload = new Payload();
+ payload.setPayloadType(PayloadType.DISCONNECT);
+ sendToServer(payload);
+ }
+
+ /**
+ * Sends a message to the server
+ *
+ * @param message
+ * @throws IOException
+ */
+ private void sendMessage(String message) throws IOException {
+ Payload payload = new Payload();
+ payload.setMessage(message);
+ payload.setPayloadType(PayloadType.MESSAGE);
+ sendToServer(payload);
+ }
+
+ /**
+ * Sends the client's name to the server (what the user desires to be called)
+ *
+ * @param name
+ * @throws IOException
+ */
+ private void sendClientName(String name) throws IOException {
+ ConnectionPayload payload = new ConnectionPayload();
+ payload.setClientName(name);
+ payload.setPayloadType(PayloadType.CLIENT_CONNECT);
+ sendToServer(payload);
+ }
+
+ private void sendToServer(Payload payload) throws IOException {
+ if (isConnected()) {
+ out.writeObject(payload);
+ out.flush(); // good practice to ensure data is written out immediately
+ } else {
+ System.out.println(
+ "Not connected to server (hint: type `/connect host:port` without the quotes and replace host/port with the necessary info)");
+ }
+ }
+ // End Send*() methods
+
+ public void start() throws IOException {
+ System.out.println("Client starting");
+
+ // Use CompletableFuture to run listenToInput() in a separate thread
+ CompletableFuture inputFuture = CompletableFuture.runAsync(this::listenToInput);
+
+ // Wait for inputFuture to complete to ensure proper termination
+ inputFuture.join();
+ }
+
+ /**
+ * Listens for messages from the server
+ */
+ private void listenToServer() {
+ try {
+ while (isRunning && isConnected()) {
+ Payload fromServer = (Payload) in.readObject(); // blocking read
+ if (fromServer != null) {
+ processPayload(fromServer);
+
+ } else {
+ System.out.println("Server disconnected");
+ break;
+ }
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (isRunning) {
+ System.out.println("Connection dropped");
+ e.printStackTrace();
+ }
+ } finally {
+ closeServerConnection();
+ }
+ System.out.println("listenToServer thread stopped");
+ }
+
+ private void processPayload(Payload payload) {
+ switch (payload.getPayloadType()) {
+ case CLIENT_CONNECT:// unused
+ break;
+ case CLIENT_ID:
+ processClientData(payload);
+ break;
+ case DISCONNECT:
+ processDisconnect(payload);
+ break;
+ case MESSAGE:
+ processMessage(payload);
+ break;
+ case REVERSE:
+ processReverse(payload);
+ break;
+ case ROOM_CREATE: // unused
+ break;
+ case ROOM_JOIN:
+ processRoomAction(payload);
+ break;
+ case ROOM_LEAVE:
+ processRoomAction(payload);
+ break;
+ case SYNC_CLIENT:
+ processRoomAction(payload);
+ break;
+ default:
+ System.out.println(TextFX.colorize("Unhandled payload type", Color.YELLOW));
+ break;
+
+ }
+ }
+
+ // Start process*() methods
+ private void processClientData(Payload payload) {
+ if (myUser.getClientId() != Constants.DEFAULT_CLIENT_ID) {
+ System.out.println(TextFX.colorize("Client ID already set, this shouldn't happen", Color.YELLOW));
+
+ }
+ myUser.setClientId(payload.getClientId());
+ myUser.setClientName(((ConnectionPayload) payload).getClientName());// confirmation from Server
+ knownClients.put(myUser.getClientId(), myUser);
+ System.out.println(TextFX.colorize("Connected", Color.GREEN));
+ }
+
+ private void processDisconnect(Payload payload) {
+ if (payload.getClientId() == myUser.getClientId()) {
+ knownClients.clear();
+ myUser.reset();
+ System.out.println(TextFX.colorize("You disconnected", Color.RED));
+ } else if (knownClients.containsKey(payload.getClientId())) {
+ User disconnectedUser = knownClients.remove(payload.getClientId());
+ if (disconnectedUser != null) {
+ System.out.println(TextFX.colorize(String.format("%s disconnected", disconnectedUser.getDisplayName()),
+ Color.RED));
+ }
+ }
+
+ }
+
+ private void processRoomAction(Payload payload) {
+ if (!(payload instanceof ConnectionPayload)) {
+ error("Invalid payload subclass for processRoomAction");
+ return;
+ }
+ ConnectionPayload connectionPayload = (ConnectionPayload) payload;
+ // use DEFAULT_CLIENT_ID to clear knownClients (mostly for disconnect and room
+ // transitions)
+ if (connectionPayload.getClientId() == Constants.DEFAULT_CLIENT_ID) {
+ knownClients.clear();
+ return;
+ }
+ switch (connectionPayload.getPayloadType()) {
+
+ case ROOM_LEAVE:
+ // remove from map
+ if (knownClients.containsKey(connectionPayload.getClientId())) {
+ knownClients.remove(connectionPayload.getClientId());
+ }
+ if (connectionPayload.getMessage() != null) {
+ System.out.println(TextFX.colorize(connectionPayload.getMessage(), Color.YELLOW));
+ }
+
+ break;
+ case ROOM_JOIN:
+ if (connectionPayload.getMessage() != null) {
+ System.out.println(TextFX.colorize(connectionPayload.getMessage(), Color.GREEN));
+ }
+ // cascade to manage knownClients
+ case SYNC_CLIENT:
+ // add to map
+ if (!knownClients.containsKey(connectionPayload.getClientId())) {
+ User user = new User();
+ user.setClientId(connectionPayload.getClientId());
+ user.setClientName(connectionPayload.getClientName());
+ knownClients.put(connectionPayload.getClientId(), user);
+ }
+ break;
+ default:
+ error("Invalid payload type for processRoomAction");
+ break;
+ }
+ }
+
+ private void processMessage(Payload payload) {
+ System.out.println(TextFX.colorize(payload.getMessage(), Color.BLUE));
+ }
+
+ private void processReverse(Payload payload) {
+ System.out.println(TextFX.colorize(payload.getMessage(), Color.PURPLE));
+ }
+ // End process*() methods
+
+ /**
+ * Listens for keyboard input from the user
+ */
+ private void listenToInput() {
+ try (Scanner si = new Scanner(System.in)) {
+ System.out.println("Waiting for input"); // moved here to avoid console spam
+ while (isRunning) { // Run until isRunning is false
+ String userInput = si.nextLine();
+ if (!processClientCommand(userInput)) {
+ sendMessage(userInput);
+ }
+ }
+ } catch (IOException ioException) {
+ System.out.println("Error in listentToInput()");
+ ioException.printStackTrace();
+ }
+ System.out.println("listenToInput thread stopped");
+ }
+
+ /**
+ * Closes the client connection and associated resources
+ */
+ private void close() {
+ isRunning = false;
+ closeServerConnection();
+ System.out.println("Client terminated");
+ // System.exit(0); // Terminate the application
+ }
+
+ /**
+ * Closes the server connection and associated resources
+ */
+ private void closeServerConnection() {
+ try {
+ if (out != null) {
+ System.out.println("Closing output stream");
+ out.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (in != null) {
+ System.out.println("Closing input stream");
+ in.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (server != null) {
+ System.out.println("Closing connection");
+ server.close();
+ System.out.println("Closed socket");
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = Client.INSTANCE;
+ try {
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/M5/Part5/Command.java b/M5/Part5/Command.java
new file mode 100644
index 0000000..008712c
--- /dev/null
+++ b/M5/Part5/Command.java
@@ -0,0 +1,33 @@
+
+package M5.Part5;
+
+import java.util.HashMap;
+
+public enum Command {
+ QUIT("quit"),
+ DISCONNECT("disconnect"),
+ LOGOUT("logout"),
+ LOGOFF("logoff"),
+ REVERSE("reverse"),
+ CREATE_ROOM("createroom"),
+ LEAVE_ROOM("leaveroom"),
+ JOIN_ROOM("joinroom"),
+ NAME("name"),
+ LIST_USERS("users");
+
+ private static final HashMap BY_COMMAND = new HashMap<>();
+ static {
+ for (Command e : values()) {
+ BY_COMMAND.put(e.command, e);
+ }
+ }
+ public final String command;
+
+ private Command(String command) {
+ this.command = command;
+ }
+
+ public static Command stringToCommand(String command) {
+ return BY_COMMAND.get(command);
+ }
+}
diff --git a/M5/Part5/ConnectionPayload.java b/M5/Part5/ConnectionPayload.java
new file mode 100644
index 0000000..a5a5c9e
--- /dev/null
+++ b/M5/Part5/ConnectionPayload.java
@@ -0,0 +1,28 @@
+
+package M5.Part5;
+
+public class ConnectionPayload extends Payload {
+ private String clientName;
+
+ /**
+ * @return the clientName
+ */
+ public String getClientName() {
+ return clientName;
+ }
+
+ /**
+ * @param clientName the clientName to set
+ */
+ public void setClientName(String clientName) {
+ this.clientName = clientName;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() +
+ String.format(" ClientName: [%s]",
+ getClientName());
+ }
+
+}
diff --git a/M5/Part5/Constants.java b/M5/Part5/Constants.java
new file mode 100644
index 0000000..86aa03a
--- /dev/null
+++ b/M5/Part5/Constants.java
@@ -0,0 +1,8 @@
+
+package M5.Part5;
+
+public abstract class Constants {
+ final public static String COMMAND_TRIGGER = "/";
+ final public static String SINGLE_SPACE = " ";
+ final public static long DEFAULT_CLIENT_ID = -1;
+}
diff --git a/M5/Part5/CustomIT114Exception.java b/M5/Part5/CustomIT114Exception.java
new file mode 100644
index 0000000..efee261
--- /dev/null
+++ b/M5/Part5/CustomIT114Exception.java
@@ -0,0 +1,12 @@
+
+package M5.Part5;
+
+public abstract class CustomIT114Exception extends Exception {
+ public CustomIT114Exception(String message) {
+ super(message);
+ }
+
+ public CustomIT114Exception(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/M5/Part5/DuplicateRoomException.java b/M5/Part5/DuplicateRoomException.java
new file mode 100644
index 0000000..ef6230e
--- /dev/null
+++ b/M5/Part5/DuplicateRoomException.java
@@ -0,0 +1,13 @@
+
+package M5.Part5;
+
+public class DuplicateRoomException extends CustomIT114Exception {
+ public DuplicateRoomException(String message) {
+ super(message);
+ }
+
+ public DuplicateRoomException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/M5/Part5/Payload.java b/M5/Part5/Payload.java
new file mode 100644
index 0000000..1191be1
--- /dev/null
+++ b/M5/Part5/Payload.java
@@ -0,0 +1,57 @@
+
+package M5.Part5;
+
+import java.io.Serializable;
+
+public class Payload implements Serializable {
+ private PayloadType payloadType;
+ private long clientId;
+ private String message;
+
+ /**
+ * @return the payloadType
+ */
+ public PayloadType getPayloadType() {
+ return payloadType;
+ }
+
+ /**
+ * @param payloadType the payloadType to set
+ */
+ public void setPayloadType(PayloadType payloadType) {
+ this.payloadType = payloadType;
+ }
+
+ /**
+ * @return the clientId
+ */
+ public long getClientId() {
+ return clientId;
+ }
+
+ /**
+ * @param clientId the clientId to set
+ */
+ public void setClientId(long clientId) {
+ this.clientId = clientId;
+ }
+
+ /**
+ * @return the message
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * @param message the message to set
+ */
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Payload[%s] Client Id [%s] Message: [%s]", getPayloadType(), getClientId(), getMessage());
+ }
+}
diff --git a/M5/Part5/PayloadType.java b/M5/Part5/PayloadType.java
new file mode 100644
index 0000000..6fd7e8a
--- /dev/null
+++ b/M5/Part5/PayloadType.java
@@ -0,0 +1,15 @@
+
+package M5.Part5;
+
+public enum PayloadType {
+ CLIENT_CONNECT, // client requesting to connect to server (passing of initialization data
+ // [name])
+ CLIENT_ID, // server sending client id
+ SYNC_CLIENT, // silent syncing of clients in room
+ DISCONNECT, // distinct disconnect action
+ ROOM_CREATE,
+ ROOM_JOIN,
+ ROOM_LEAVE,
+ REVERSE,
+ MESSAGE // sender and message
+}
diff --git a/M5/Part5/Room.java b/M5/Part5/Room.java
new file mode 100644
index 0000000..18c7c53
--- /dev/null
+++ b/M5/Part5/Room.java
@@ -0,0 +1,264 @@
+
+package M5.Part5;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import M5.Part5.TextFX.Color;
+
+public class Room implements AutoCloseable {
+ private final String name;// unique name of the Room
+ private volatile boolean isRunning = false;
+ private final ConcurrentHashMap clientsInRoom = new ConcurrentHashMap();
+
+ public final static String LOBBY = "lobby";
+
+ private void info(String message) {
+ System.out.println(TextFX.colorize(String.format("Room[%s]: %s", name, message), Color.PURPLE));
+ }
+
+ public Room(String name) {
+ this.name = name;
+ isRunning = true;
+ info("Created");
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ protected synchronized void addClient(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ if (clientsInRoom.containsKey(client.getClientId())) {
+ info("Attempting to add a client that already exists in the room");
+ return;
+ }
+ clientsInRoom.put(client.getClientId(), client);
+ client.setCurrentRoom(this);
+ client.sendResetUserList();
+ syncExistingClients(client);
+ // notify clients of someone joining
+ joinStatusRelay(client, true);
+
+ }
+
+ protected synchronized void removeClient(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ if (!clientsInRoom.containsKey(client.getClientId())) {
+ info("Attempting to remove a client that doesn't exist in the room");
+ return;
+ }
+ ServerThread removedClient = clientsInRoom.get(client.getClientId());
+ if (removedClient != null) {
+ // notify clients of someone joining
+ joinStatusRelay(removedClient, false);
+ clientsInRoom.remove(client.getClientId());
+ autoCleanup();
+ }
+ }
+
+ private void syncExistingClients(ServerThread incomingClient) {
+ clientsInRoom.values().forEach(serverThread -> {
+ if (serverThread.getClientId() != incomingClient.getClientId()) {
+ boolean failedToSync = !incomingClient.sendClientInfo(serverThread.getClientId(),
+ serverThread.getClientName(), RoomAction.JOIN, true);
+ if (failedToSync) {
+ System.out.println(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ }
+ });
+ }
+
+ private void joinStatusRelay(ServerThread client, boolean didJoin) {
+ clientsInRoom.values().removeIf(serverThread -> {
+ String formattedMessage = String.format("Room[%s] %s %s the room",
+ getName(),
+ client.getClientId() == serverThread.getClientId() ? "You"
+ : client.getDisplayName(),
+ didJoin ? "joined" : "left");
+ final long senderId = client == null ? Constants.DEFAULT_CLIENT_ID : client.getClientId();
+ // Share info of the client joining or leaving the room
+ boolean failedToSync = !serverThread.sendClientInfo(client.getClientId(),
+ client.getClientName(), didJoin ? RoomAction.JOIN : RoomAction.LEAVE);
+ // Send the server generated message to the current client
+ boolean failedToSend = !serverThread.sendMessage(senderId, formattedMessage);
+ if (failedToSend || failedToSync) {
+ System.out.println(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Sends a basic String message from the sender to all connectedClients
+ * Internally calls processCommand and evaluates as necessary.
+ * Note: Clients that fail to receive a message get removed from
+ * connectedClients.
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ protected synchronized void relay(ServerThread sender, String message) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+
+ // Note: any desired changes to the message must be done before this line
+ String senderString = sender == null ? String.format("Room[%s]", getName())
+ : sender.getDisplayName();
+ final long senderId = sender == null ? Constants.DEFAULT_CLIENT_ID : sender.getClientId();
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't be changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+
+ // loop over clients and send out the message; remove client if message failed
+ // to be sent
+ // Note: this uses a lambda expression for each item in the values() collection,
+ // it's one way we can safely remove items during iteration
+ info(String.format("sending message to %s recipients: %s", clientsInRoom.size(), formattedMessage));
+
+ clientsInRoom.values().removeIf(serverThread -> {
+ boolean failedToSend = !serverThread.sendMessage(senderId, formattedMessage);
+ if (failedToSend) {
+ System.out.println(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Takes a ServerThread and removes them from the Server
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param client
+ */
+ private synchronized void disconnect(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ ServerThread disconnectingServerThread = clientsInRoom.remove(client.getClientId());
+ if (disconnectingServerThread != null) {
+
+ clientsInRoom.values().removeIf(serverThread -> {
+ if (serverThread.getClientId() == disconnectingServerThread.getClientId()) {
+ return true;
+ }
+ boolean failedToSend = !serverThread.sendClientInfo(disconnectingServerThread.getClientId(),
+ disconnectingServerThread.getClientName(), RoomAction.LEAVE);
+ if (failedToSend) {
+ System.out.println(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ relay(null, disconnectingServerThread.getDisplayName() + " disconnected");
+ disconnectingServerThread.disconnect();
+ }
+ autoCleanup();
+ }
+
+ protected synchronized void disconnectAll() {
+ info("Disconnect All triggered");
+ if (!isRunning) {
+ return;
+ }
+ clientsInRoom.values().removeIf(client -> {
+ disconnect(client);
+ return true;
+ });
+ info("Disconnect All finished");
+ }
+
+ /**
+ * Attempts to close the room to free up resources if it's empty
+ */
+ private void autoCleanup() {
+ if (!Room.LOBBY.equalsIgnoreCase(name) && clientsInRoom.isEmpty()) {
+ close();
+ }
+ }
+
+ @Override
+ public void close() {
+ // attempt to gracefully close and migrate clients
+ if (!clientsInRoom.isEmpty()) {
+ relay(null, "Room is shutting down, migrating to lobby");
+ info(String.format("migrating %s clients", clientsInRoom.size()));
+ clientsInRoom.values().removeIf(client -> {
+ try {
+ Server.INSTANCE.joinRoom(Room.LOBBY, client);
+ } catch (RoomNotFoundException e) {
+ e.printStackTrace();
+ // TODO, fill in, this shouldn't happen though
+ }
+ return true;
+ });
+ }
+ Server.INSTANCE.removeRoom(this);
+ isRunning = false;
+ clientsInRoom.clear();
+ info(String.format("closed"));
+ }
+
+ // start handle methods
+ public void handleCreateRoom(ServerThread sender, String roomName) {
+ try {
+ Server.INSTANCE.createRoom(roomName);
+ Server.INSTANCE.joinRoom(roomName, sender);
+ } catch (RoomNotFoundException e) {
+ info("Room wasn't found (this shouldn't happen)");
+ e.printStackTrace();
+ } catch (DuplicateRoomException e) {
+ sender.sendMessage(Constants.DEFAULT_CLIENT_ID, String.format("Room %s already exists", roomName));
+ }
+ }
+
+ public void handleJoinRoom(ServerThread sender, String roomName) {
+ try {
+ Server.INSTANCE.joinRoom(roomName, sender);
+ } catch (RoomNotFoundException e) {
+ sender.sendMessage(Constants.DEFAULT_CLIENT_ID, String.format("Room %s doesn't exist", roomName));
+ }
+ }
+
+ protected synchronized void handleDisconnect(BaseServerThread sender) {
+ handleDisconnect((ServerThread) sender);
+ }
+
+ /**
+ * Expose access to the disconnect action
+ *
+ * @param serverThread
+ */
+ protected synchronized void handleDisconnect(ServerThread sender) {
+ disconnect(sender);
+ }
+
+ protected synchronized void handleReverseText(ServerThread sender, String text) {
+ StringBuilder sb = new StringBuilder(text);
+ sb.reverse();
+ String rev = sb.toString();
+ relay(sender, rev);
+ }
+
+ protected synchronized void handleMessage(ServerThread sender, String text) {
+ relay(sender, text);
+ }
+ // end handle methods
+}
diff --git a/M5/Part5/RoomAction.java b/M5/Part5/RoomAction.java
new file mode 100644
index 0000000..92bde03
--- /dev/null
+++ b/M5/Part5/RoomAction.java
@@ -0,0 +1,6 @@
+
+package M5.Part5;
+
+public enum RoomAction {
+ CREATE, JOIN, LEAVE
+}
diff --git a/M5/Part5/RoomNotFoundException.java b/M5/Part5/RoomNotFoundException.java
new file mode 100644
index 0000000..eb90a20
--- /dev/null
+++ b/M5/Part5/RoomNotFoundException.java
@@ -0,0 +1,14 @@
+
+package M5.Part5;
+
+public class RoomNotFoundException extends CustomIT114Exception {
+
+ public RoomNotFoundException(String message) {
+ super(message);
+ }
+
+ public RoomNotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/M5/Part5/Server.java b/M5/Part5/Server.java
new file mode 100644
index 0000000..7a2cee5
--- /dev/null
+++ b/M5/Part5/Server.java
@@ -0,0 +1,200 @@
+
+package M5.Part5;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.ConcurrentHashMap;
+
+import M5.Part5.TextFX.Color;
+
+public enum Server {
+ INSTANCE; // Singleton instance
+
+ private int port = 3000;
+ // connected clients
+ // Use ConcurrentHashMap for thread-safe client management
+ // The key is the unique Room name and the Room is the instance
+ private final ConcurrentHashMap rooms = new ConcurrentHashMap<>();
+ private boolean isRunning = true;
+ private long nextClientId = 0;
+
+ private void info(String message) {
+ System.out.println(TextFX.colorize(String.format("Server: %s", message), Color.YELLOW));
+ }
+
+ private Server() {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ info("JVM is shutting down. Perform cleanup tasks.");
+ shutdown();
+ }));
+ }
+
+ /**
+ * Gracefully disconnect clients
+ */
+ private void shutdown() {
+ try {
+ // chose removeIf over forEach to avoid potential
+ // ConcurrentModificationException
+ // since empty rooms tell the server to remove themselves
+ rooms.values().removeIf(room -> {
+ room.disconnectAll();
+ return true;
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void start(int port) {
+ this.port = port;
+ // server listening
+ info("Listening on port " + this.port);
+ // Simplified client connection loop
+ try (ServerSocket serverSocket = new ServerSocket(port)) {
+ createRoom(Room.LOBBY);// create the first room (lobby)
+ while (isRunning) {
+ info("Waiting for next client");
+ Socket incomingClient = serverSocket.accept(); // blocking action, waits for a client connection
+ info("Client connected");
+ // wrap socket in a ServerThread, pass a callback to notify the Server when
+ // they're initialized
+ ServerThread serverThread = new ServerThread(incomingClient, this::onServerThreadInitialized);
+ // start the thread (typically an external entity manages the lifecycle and we
+ // don't have the thread start itself)
+ serverThread.start();
+ // Note: We don't yet add the ServerThread reference to our connectedClients map
+ }
+ } catch (DuplicateRoomException e) {
+ System.err.println(TextFX.colorize("Lobby already exists (this shouldn't happen)", Color.RED));
+ } catch (IOException e) {
+ System.err.println(TextFX.colorize("Error accepting connection", Color.RED));
+ e.printStackTrace();
+ } finally {
+ info("Closing server socket");
+ }
+ }
+
+ /**
+ * Callback passed to ServerThread to inform Server they're ready to receive
+ * data
+ *
+ * @param serverThread
+ */
+ private void onServerThreadInitialized(ServerThread serverThread) {
+ // Generate Server controlled clientId
+ nextClientId = Math.max(++nextClientId, 1);
+ serverThread.setClientId(nextClientId);
+ serverThread.sendClientId();// syncs the data to the Client
+ // add initialized client to the lobby
+ info(String.format("*%s initialized*", serverThread.getDisplayName()));
+ try {
+ joinRoom(Room.LOBBY, serverThread);
+ info(String.format("*%s added to Lobby*", serverThread.getDisplayName()));
+ } catch (RoomNotFoundException e) {
+ info(String.format("*Error adding %s to Lobby*", serverThread.getDisplayName()));
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Attempts to create a new Room and add it to the tracked rooms collection
+ *
+ * @param name Unique name of the room
+ * @return true if it was created and false if it wasn't
+ * @throws DuplicateRoomException
+ */
+ protected void createRoom(String name) throws DuplicateRoomException {
+ final String nameCheck = name.toLowerCase();
+ if (rooms.containsKey(nameCheck)) {
+ throw new DuplicateRoomException(String.format("Room %s already exists", name));
+ }
+ Room room = new Room(name);
+ rooms.put(nameCheck, room);
+ info(String.format("Created new Room %s", name));
+ }
+
+ /**
+ * Attempts to move a client (ServerThread) between rooms
+ *
+ * @param name the target room to join
+ * @param client the client moving
+ * @throws RoomNotFoundException
+ *
+ */
+ protected void joinRoom(String name, ServerThread client) throws RoomNotFoundException {
+ final String nameCheck = name.toLowerCase();
+ if (!rooms.containsKey(nameCheck)) {
+ throw new RoomNotFoundException(String.format("Room %s wasn't found", name));
+ }
+ Room currentRoom = client.getCurrentRoom();
+ if (currentRoom != null) {
+ info("Removing client from previous Room " + currentRoom.getName());
+ currentRoom.removeClient(client);
+ }
+ Room next = rooms.get(nameCheck);
+ next.addClient(client);
+ }
+
+ protected void removeRoom(Room room) {
+ rooms.remove(room.getName().toLowerCase());
+ info(String.format("Removed room %s", room.getName()));
+ }
+
+ /**
+ *
+ *
+ * Note: Not a common use-case; just updated for example sake.
+ *
+ * Relays the message from the sender to all rooms
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ private synchronized void relayToAllRooms(ServerThread sender, String message) {
+ // Note: any desired changes to the message must be done before this line
+ String senderString = sender == null ? "Server" : sender.getDisplayName();
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+ // end temp identifier
+
+ // loop over Rooms and send out the message
+ // Note: this uses a lambda expression for each item in the values() collection
+
+ rooms.values().forEach(room -> {
+ room.relay(sender, formattedMessage);
+ });
+ }
+
+ /**
+ * Used to send a message to all Rooms.
+ * This is just an example and we likely won't be using this
+ *
+ * @param sender
+ * @param message
+ */
+ public synchronized void broadcastMessageToAllRooms(ServerThread sender, String message) {
+ relayToAllRooms(sender, message);
+ }
+
+ public static void main(String[] args) {
+ System.out.println("Server Starting");
+ Server server = Server.INSTANCE;
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can ignore, will either be index out of bounds or type mismatch
+ // will default to the defined value prior to the try/catch
+ }
+ server.start(port);
+ System.out.println("Server Stopped");
+ }
+
+}
diff --git a/M5/Part5/ServerThread.java b/M5/Part5/ServerThread.java
new file mode 100644
index 0000000..24e5c08
--- /dev/null
+++ b/M5/Part5/ServerThread.java
@@ -0,0 +1,167 @@
+
+package M5.Part5;
+
+import java.net.Socket;
+import java.util.Objects;
+import java.util.function.Consumer;
+import M5.Part5.TextFX.Color;
+
+/**
+ * A server-side representation of a single client
+ */
+public class ServerThread extends BaseServerThread {
+ private Consumer onInitializationComplete; // callback to inform when this object is ready
+
+ /**
+ * A wrapper method so we don't need to keep typing out the long/complex sysout
+ * line inside
+ *
+ * @param message
+ */
+ protected void info(String message) {
+ System.out.println(TextFX.colorize(String.format("Thread[%s]: %s", this.getClientId(), message), Color.CYAN));
+ }
+
+ /**
+ * Wraps the Socket connection and takes a Server reference and a callback
+ *
+ * @param myClient
+ * @param server
+ * @param onInitializationComplete method to inform listener that this object is
+ * ready
+ */
+ protected ServerThread(Socket myClient, Consumer onInitializationComplete) {
+ Objects.requireNonNull(myClient, "Client socket cannot be null");
+ Objects.requireNonNull(onInitializationComplete, "callback cannot be null");
+ info("ServerThread created");
+ // get communication channels to single client
+ this.client = myClient;
+ // this.clientId = this.threadId(); // An id associated with the thread
+ // instance, used as a temporary identifier
+ this.onInitializationComplete = onInitializationComplete;
+
+ }
+
+ // Start Send*() Methods
+ protected boolean sendDisconnect(long clientId) {
+ Payload payload = new Payload();
+ payload.setClientId(clientId);
+ payload.setPayloadType(PayloadType.DISCONNECT);
+ return sendToClient(payload);
+ }
+
+ protected boolean sendResetUserList() {
+ return sendClientInfo(Constants.DEFAULT_CLIENT_ID, null, RoomAction.JOIN);
+ }
+
+ /**
+ * Syncs Client Info (id, name, join status) to the client
+ *
+ * @param clientId use -1 for reset/clear
+ * @param clientName
+ * @param action RoomAction of Join or Leave
+ * @return true for successful send
+ */
+ protected boolean sendClientInfo(long clientId, String clientName, RoomAction action) {
+ return sendClientInfo(clientId, clientName, action, false);
+ }
+
+ /**
+ * Syncs Client Info (id, name, join status) to the client
+ *
+ * @param clientId use -1 for reset/clear
+ * @param clientName
+ * @param action RoomAction of Join or Leave
+ * @param isSync True is used to not show output on the client side (silent
+ * sync)
+ * @return true for successful send
+ */
+ protected boolean sendClientInfo(long clientId, String clientName, RoomAction action, boolean isSync) {
+ ConnectionPayload payload = new ConnectionPayload();
+ switch (action) {
+ case JOIN:
+ payload.setPayloadType(PayloadType.ROOM_JOIN);
+ break;
+ case LEAVE:
+ payload.setPayloadType(PayloadType.ROOM_LEAVE);
+ break;
+ default:
+ break;
+ }
+ if (isSync) {
+ payload.setPayloadType(PayloadType.SYNC_CLIENT);
+ }
+ payload.setClientId(clientId);
+ payload.setClientName(clientName);
+ return sendToClient(payload);
+ }
+
+ /**
+ * Sends this client's id to the client.
+ * This will be a successfully connection handshake
+ *
+ * @return true for successful send
+ */
+ protected boolean sendClientId() {
+ ConnectionPayload payload = new ConnectionPayload();
+ payload.setPayloadType(PayloadType.CLIENT_ID);
+ payload.setClientId(getClientId());
+ payload.setClientName(getClientName());// Can be used as a Server-side override of username (i.e., profanity
+ // filter)
+ return sendToClient(payload);
+ }
+
+ /**
+ * Sends a message to the client
+ *
+ * @param clientId who it's from
+ * @param message
+ * @return true for successful send
+ */
+ protected boolean sendMessage(long clientId, String message) {
+ Payload payload = new Payload();
+ payload.setPayloadType(PayloadType.MESSAGE);
+ payload.setMessage(message);
+ payload.setClientId(clientId);
+ return sendToClient(payload);
+ }
+
+ // End Send*() Methods
+ @Override
+ protected void processPayload(Payload incoming) {
+
+ switch (incoming.getPayloadType()) {
+ case CLIENT_CONNECT:
+ setClientName(((ConnectionPayload) incoming).getClientName().trim());
+
+ break;
+ case DISCONNECT:
+ currentRoom.handleDisconnect(this);
+ break;
+ case MESSAGE:
+ currentRoom.handleMessage(this, incoming.getMessage());
+ break;
+ case REVERSE:
+ currentRoom.handleReverseText(this, incoming.getMessage());
+ break;
+ case ROOM_CREATE:
+ currentRoom.handleCreateRoom(this, incoming.getMessage());
+ break;
+ case ROOM_JOIN:
+ currentRoom.handleJoinRoom(this, incoming.getMessage());
+ break;
+ case ROOM_LEAVE:
+ currentRoom.handleJoinRoom(this, Room.LOBBY);
+ break;
+ default:
+ System.out.println(TextFX.colorize("Unknown payload type received", Color.RED));
+ break;
+ }
+ }
+
+ @Override
+ protected void onInitialized() {
+ // once receiving the desired client name the object is ready
+ onInitializationComplete.accept(this);
+ }
+}
diff --git a/M5/Part5/TextFX.java b/M5/Part5/TextFX.java
new file mode 100644
index 0000000..dc717a3
--- /dev/null
+++ b/M5/Part5/TextFX.java
@@ -0,0 +1,72 @@
+package M5.Part5;
+
+/**
+ * Utility to attempt to provide colored text in the terminal.
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+public abstract class TextFX {
+
+ /**
+ * TextFX.Color list of available colors
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+ public enum Color {
+ BLACK("\033[0;30m"),
+ RED("\033[0;31m"),
+ GREEN("\033[0;32m"),
+ YELLOW("\033[0;33m"),
+ BLUE("\033[0;34m"),
+ PURPLE("\033[0;35m"),
+ CYAN("\033[0;36m"),
+ WHITE("\033[0;37m");
+
+ private final String code;
+
+ Color(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+ }
+
+ public static final String RESET = "\033[0m";
+
+ /**
+ * Generates a String with the original message wrapped in the ASCII of the
+ * color and RESET
+ *
+ *
+ * Note: May not work for all terminals
+ *
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ *
+ * @param text Input text to colorize
+ * @param color Enum of Color choice from TextFX.Color
+ * @return wrapped String
+ */
+ public static String colorize(String text, Color color) {
+ StringBuilder builder = new StringBuilder();
+ builder.append(color.getCode());
+ builder.append(text);
+ builder.append(RESET);
+ return builder.toString();
+ }
+
+ public static void main(String[] args) {
+ // Example usage:
+ System.out.println(TextFX.colorize("Hello, world!", Color.RED));
+ System.out.println(TextFX.colorize("This is some blue text.", Color.BLUE));
+ System.out.println(TextFX.colorize("And this is green!", Color.GREEN));
+ }
+}
\ No newline at end of file
diff --git a/M5/Part5/User.java b/M5/Part5/User.java
new file mode 100644
index 0000000..a180490
--- /dev/null
+++ b/M5/Part5/User.java
@@ -0,0 +1,43 @@
+package M5.Part5;
+
+public class User {
+ private long clientId = Constants.DEFAULT_CLIENT_ID;
+ private String clientName;
+
+ /**
+ * @return the clientId
+ */
+ public long getClientId() {
+ return clientId;
+ }
+
+ /**
+ * @param clientId the clientId to set
+ */
+ public void setClientId(long clientId) {
+ this.clientId = clientId;
+ }
+
+ /**
+ * @return the username
+ */
+ public String getClientName() {
+ return clientName;
+ }
+
+ /**
+ * @param username the username to set
+ */
+ public void setClientName(String username) {
+ this.clientName = username;
+ }
+
+ public String getDisplayName() {
+ return String.format("%s#%s", this.clientName, this.clientId);
+ }
+
+ public void reset() {
+ this.clientId = Constants.DEFAULT_CLIENT_ID;
+ this.clientName = null;
+ }
+}
\ No newline at end of file
diff --git a/Project/Client/Client.java b/Project/Client/Client.java
new file mode 100644
index 0000000..daf9295
--- /dev/null
+++ b/Project/Client/Client.java
@@ -0,0 +1,726 @@
+package Project.Client;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.List;
+import java.util.Scanner;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import Project.Common.Command;
+import Project.Common.ConnectionPayload;
+import Project.Common.Constants;
+import Project.Common.LoggerUtil;
+import Project.Common.Payload;
+import Project.Common.PayloadType;
+import Project.Common.Phase;
+import Project.Common.ReadyPayload;
+import Project.Common.RoomAction;
+import Project.Common.RoomResultPayload;
+import Project.Common.PickPayload;
+import Project.Common.PointsPayload;
+import Project.Common.TextFX;
+import Project.Common.User;
+import Project.Common.TextFX.Color;
+
+/**
+ * Demonstrates two-way communication between a client and server
+ * in a multi-user environment.
+ */
+public enum Client {
+ INSTANCE;
+
+ {
+ // Configure the client-side logger when the enum is initialized
+ LoggerUtil.LoggerConfig config = new LoggerUtil.LoggerConfig();
+ config.setFileSizeLimit(2048 * 1024); // max log file size: 2MB
+ config.setFileCount(1);
+ config.setLogLocation("client.log");
+ // Apply the logger settings
+ LoggerUtil.INSTANCE.setConfig(config);
+ }
+ private Socket server = null;
+ private ObjectOutputStream out = null;
+ private ObjectInputStream in = null;
+ final Pattern ipAddressPattern = Pattern
+ .compile("/connect\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{3,5})");
+ final Pattern localhostPattern = Pattern.compile("/connect\\s+(localhost:\\d{3,5})");
+ private volatile boolean isRunning = true; // volatile for proper visibility across threads
+ private final ConcurrentHashMap knownClients = new ConcurrentHashMap();
+ private User myUser = new User();
+ private Phase currentPhase = Phase.READY;
+
+ private void error(String message) {
+ LoggerUtil.INSTANCE.severe(TextFX.colorize(String.format("%s", message), Color.RED));
+ }
+
+ // Private constructor since enum handles instance management
+ private Client() {
+ LoggerUtil.INSTANCE.info("Client Created");
+ }
+
+ public boolean isConnected() {
+ if (server == null) {
+ return false;
+ }
+ // https://stackoverflow.com/a/10241044
+ // Note: these checks only verify the client's side of the socket;
+ // they don't reliably indicate server-side issues and are mainly
+ // included as an instructional example.
+ return server.isConnected() && !server.isClosed() && !server.isInputShutdown() && !server.isOutputShutdown();
+ }
+
+ /**
+ * Attempts to open a socket connection to a server using the given
+ * IP address and port number.
+ *
+ * @param address server IP or hostname
+ * @param port server port
+ * @return true if the socket successfully connects
+ */
+ private boolean connect(String address, int port) {
+ try {
+ server = new Socket(address, port);
+ // stream used to send objects to the server
+ out = new ObjectOutputStream(server.getOutputStream());
+ // stream used to receive objects from the server
+ in = new ObjectInputStream(server.getInputStream());
+ LoggerUtil.INSTANCE.info("Client connected");
+ // Run listenToServer() asynchronously on a separate thread
+ CompletableFuture.runAsync(this::listenToServer);
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return isConnected();
+ }
+
+ /**
+ *
+ * Checks whether the input text contains the connect command
+ * followed by a valid IP address and port or localhost and port.
+ *
+ *
+ * Example format: 123.123.123.123:3000
+ *
+ *
+ * Example format: localhost:3000
+ *
+ * https://www.w3schools.com/java/java_regex.asp
+ *
+ * @param text user input to evaluate
+ * @return true if the string represents a well-formed connection command
+ */
+ private boolean isConnection(String text) {
+ Matcher ipMatcher = ipAddressPattern.matcher(text);
+ Matcher localhostMatcher = localhostPattern.matcher(text);
+ return ipMatcher.matches() || localhostMatcher.matches();
+ }
+
+ /**
+ * Central handler for user-entered commands.
+ *
+ * Extend this with additional command handling as needed.
+ *
+ *
+ * @param text full text entered by the user
+ * @return true if the input was recognized as a command or caused a command to run
+ * @throws IOException if sending data to the server fails
+ */
+ private boolean processClientCommand(String text) throws IOException {
+ boolean wasCommand = false;
+ if (text.startsWith(Constants.COMMAND_TRIGGER)) {
+ text = text.substring(1); // strip the leading '/'
+ // System.out.println("Checking command: " + text);
+ if (isConnection("/" + text)) {
+ if (myUser.getClientName() == null || myUser.getClientName().isEmpty()) {
+ LoggerUtil.INSTANCE.warning(
+ TextFX.colorize("Please set your name via /name before connecting", Color.RED));
+ return true;
+ }
+ // collapse repeated spaces into a single space
+ // split on the space after "connect" to isolate host and port
+ // then split on ":" to separate host (index 0) and port (index 1)
+ String[] parts = text.trim().replaceAll(" +", " ").split(" ")[1].split(":");
+ connect(parts[0].trim(), Integer.parseInt(parts[1].trim()));
+ sendClientName(myUser.getClientName()); // send follow-up identification (handshake)
+ wasCommand = true;
+ } else if (text.startsWith(Command.NAME.command)) {
+ text = text.replace(Command.NAME.command, "").trim();
+ if (text == null || text.length() == 0) {
+ LoggerUtil.INSTANCE
+ .warning(TextFX.colorize("This command requires a name as an argument", Color.RED));
+ return true;
+ }
+ myUser.setClientName(text); // temporary value until server confirms
+ LoggerUtil.INSTANCE.info(TextFX.colorize(String.format("Name set to %s", myUser.getClientName()),
+ Color.YELLOW));
+ wasCommand = true;
+ } else if (text.trim().equalsIgnoreCase(Command.LIST_USERS.command)) {
+ String message = TextFX.colorize("Known clients:\n", Color.CYAN);
+ LoggerUtil.INSTANCE.info(TextFX.colorize("Known clients:", Color.CYAN));
+ message += String.join("\n", knownClients.values().stream()
+ .map(c -> String.format("%s %s %s %s",
+ c.getDisplayName(),
+ c.getClientId() == myUser.getClientId() ? " (you)" : "",
+ c.isReady() ? "[x]" : "[ ]",
+ c.didTakeTurn() ? "[T]" : "[ ]"))
+ .toList());
+ LoggerUtil.INSTANCE.info(message);
+ wasCommand = true;
+ } else if (Command.QUIT.command.equalsIgnoreCase(text.trim())) {
+ close();
+ wasCommand = true;
+ } else if (Command.DISCONNECT.command.equalsIgnoreCase(text.trim())) {
+ sendDisconnect();
+ wasCommand = true;
+ } else if (text.startsWith(Command.REVERSE.command)) {
+ text = text.replace(Command.REVERSE.command, "").trim();
+ sendReverse(text);
+ wasCommand = true;
+ } else if (text.startsWith(Command.CREATE_ROOM.command)) {
+ text = text.replace(Command.CREATE_ROOM.command, "").trim();
+ if (text == null || text.length() == 0) {
+ LoggerUtil.INSTANCE
+ .warning(TextFX.colorize("This command requires a room name as an argument", Color.RED));
+ return true;
+ }
+ sendRoomAction(text, RoomAction.CREATE);
+ wasCommand = true;
+ } else if (text.startsWith(Command.JOIN_ROOM.command)) {
+ text = text.replace(Command.JOIN_ROOM.command, "").trim();
+ if (text == null || text.length() == 0) {
+ LoggerUtil.INSTANCE
+ .warning(TextFX.colorize("This command requires a room name as an argument", Color.RED));
+ return true;
+ }
+ sendRoomAction(text, RoomAction.JOIN);
+ wasCommand = true;
+ } else if (text.startsWith(Command.LEAVE_ROOM.command) || text.startsWith("leave")) {
+ // Note: Handles /leave, /leaveroom, and any command that begins with "/leave"
+ sendRoomAction(text, RoomAction.LEAVE);
+ wasCommand = true;
+ } else if (text.startsWith(Command.LIST_ROOMS.command)) {
+ text = text.replace(Command.LIST_ROOMS.command, "").trim();
+
+ sendRoomAction(text, RoomAction.LIST);
+ wasCommand = true;
+ } else if (text.trim().equalsIgnoreCase(Command.READY.command)) {
+ sendReady();
+ wasCommand = true;
+ } else if (text.startsWith(Command.EXAMPLE_TURN.command)) {
+ text = text.replace(Command.EXAMPLE_TURN.command, "").trim();
+
+ sendDoTurn(text);
+ wasCommand = true;
+ } else if (text.startsWith("pick")) {
+ text = text.replace("pick", "").trim();
+ if (text == null || text.length() == 0) {
+ LoggerUtil.INSTANCE
+ .warning(TextFX.colorize("This command requires a choice r, p, or s", Color.RED));
+ return true;
+ }
+ sendPick(text.trim());
+ wasCommand = true;
+ } else if (text.trim().equalsIgnoreCase(Command.SCOREBOARD.command)) {
+ sendScoreboardRequest();
+ wasCommand = true;
+ }
+ }
+ return wasCommand;
+ }
+
+ // Begin Send*() helper methods
+ private void sendDoTurn(String text) throws IOException {
+ // NOTE: currently reusing ReadyPayload since it already contains the fields we need
+ // A dedicated turn payload could include more details specific to your project
+ ReadyPayload rp = new ReadyPayload();
+ rp.setPayloadType(PayloadType.TURN);
+ rp.setReady(true); // <- technically unnecessary since payload type is the main trigger
+ rp.setMessage(text);
+ sendToServer(rp);
+ }
+
+ // rk975 - 11/26/25
+ // Sends the player's choice of "r", "p", or "s" to the server.
+ // Validates input before sending.
+ // Throws IOException if sending fails.
+ private void sendPick(String text) throws IOException {
+ String c = text.toLowerCase();
+ if (!(c.equals("r") || c.equals("p") || c.equals("s"))) {
+ LoggerUtil.INSTANCE.warning(TextFX.colorize("Invalid pick. Use r, p, or s", Color.RED));
+ return;
+ }
+ PickPayload pp = new PickPayload();
+ pp.setPayloadType(PayloadType.PICK);
+ pp.setChoice(c);
+ sendToServer(pp);
+ }
+
+ private void sendScoreboardRequest() throws IOException {
+ Payload p = new Payload();
+ p.setPayloadType(PayloadType.SCOREBOARD);
+ sendToServer(p);
+ }
+
+ /**
+ * Informs the server that this client is ready.
+ * On the server side, this could also be interpreted as a toggle
+ * depending on implementation.
+ *
+ * @throws IOException if sending the payload fails
+ */
+ private void sendReady() throws IOException {
+ ReadyPayload rp = new ReadyPayload();
+ // rp.setReady(true); // <- not required if server only checks payload type
+ sendToServer(rp);
+ }
+
+ /**
+ * Sends an action related to room management to the server.
+ *
+ * @param roomName the name of the room being targeted
+ * @param roomAction type of room operation (join, leave, create, list)
+ * @throws IOException if there is a problem communicating with the server
+ */
+ private void sendRoomAction(String roomName, RoomAction roomAction) throws IOException {
+ Payload payload = new Payload();
+ payload.setMessage(roomName);
+ switch (roomAction) {
+ case RoomAction.CREATE:
+ payload.setPayloadType(PayloadType.ROOM_CREATE);
+ break;
+ case RoomAction.JOIN:
+ payload.setPayloadType(PayloadType.ROOM_JOIN);
+ break;
+ case RoomAction.LEAVE:
+ payload.setPayloadType(PayloadType.ROOM_LEAVE);
+ break;
+ case RoomAction.LIST:
+ payload.setPayloadType(PayloadType.ROOM_LIST);
+ break;
+ default:
+ LoggerUtil.INSTANCE.warning(TextFX.colorize("Invalid room action", Color.RED));
+ break;
+ }
+ sendToServer(payload);
+ }
+
+ /**
+ * Sends a "reverse message" request to the server.
+ *
+ * @param message the original string to be reversed or processed
+ * @throws IOException if sending fails
+ */
+ private void sendReverse(String message) throws IOException {
+ Payload payload = new Payload();
+ payload.setMessage(message);
+ payload.setPayloadType(PayloadType.REVERSE);
+ sendToServer(payload);
+
+ }
+
+ /**
+ * Notifies the server that this client wishes to disconnect.
+ *
+ * @throws IOException if sending fails
+ */
+ private void sendDisconnect() throws IOException {
+ Payload payload = new Payload();
+ payload.setPayloadType(PayloadType.DISCONNECT);
+ sendToServer(payload);
+ }
+
+ /**
+ * Sends a general chat/message payload to the server.
+ *
+ * @param message content to send to other clients via the server
+ * @throws IOException if writing to the output stream fails
+ */
+ private void sendMessage(String message) throws IOException {
+ Payload payload = new Payload();
+ payload.setMessage(message);
+ payload.setPayloadType(PayloadType.MESSAGE);
+ sendToServer(payload);
+ }
+
+ /**
+ * Sends the user's preferred display name to the server so it knows
+ * how to refer to this client.
+ *
+ * @param name desired display name
+ * @throws IOException if the payload cannot be sent
+ */
+ private void sendClientName(String name) throws IOException {
+ ConnectionPayload payload = new ConnectionPayload();
+ payload.setClientName(name);
+ payload.setPayloadType(PayloadType.CLIENT_CONNECT);
+ sendToServer(payload);
+ }
+
+ private void sendToServer(Payload payload) throws IOException {
+ if (isConnected()) {
+ out.writeObject(payload);
+ out.flush(); // ensures data is pushed out immediately
+ } else {
+ LoggerUtil.INSTANCE.warning(
+ "Not connected to server (hint: type `/connect host:port` without the quotes and replace host/port with the necessary info)");
+ }
+ }
+ // End Send*() helper methods
+
+ public void start() throws IOException {
+ LoggerUtil.INSTANCE.info("Client starting");
+
+ // Run listenToInput() on a separate thread using CompletableFuture
+ CompletableFuture inputFuture = CompletableFuture.runAsync(this::listenToInput);
+
+ // Block until the input-handling thread finishes to allow a clean shutdown
+ inputFuture.join();
+ }
+
+ /**
+ * Continuously listens for incoming data from the server and
+ * dispatches it to the appropriate handler.
+ */
+ private void listenToServer() {
+ try {
+ while (isRunning && isConnected()) {
+ Payload fromServer = (Payload) in.readObject(); // blocking read until an object arrives
+ if (fromServer != null) {
+ processPayload(fromServer);
+
+ } else {
+ LoggerUtil.INSTANCE.info("Server disconnected");
+ break;
+ }
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ LoggerUtil.INSTANCE.severe("Error reading object as specified type:", cce);
+ // cce.printStackTrace();
+ } catch (IOException e) {
+ if (isRunning) {
+ LoggerUtil.INSTANCE.warning("Connection dropped");
+ e.printStackTrace();
+ }
+ } catch (Exception e) {
+ LoggerUtil.INSTANCE.severe("Unexpected error in listenToServer()", e);
+ } finally {
+ closeServerConnection();
+ }
+ LoggerUtil.INSTANCE.info("listenToServer thread stopped");
+ }
+
+ private void processPayload(Payload payload) {
+ switch (payload.getPayloadType()) {
+ case CLIENT_CONNECT: // unused
+ break;
+ case CLIENT_ID:
+ processClientData(payload);
+ break;
+ case DISCONNECT:
+ processDisconnect(payload);
+ break;
+ case MESSAGE:
+ processMessage(payload);
+ break;
+ case REVERSE:
+ processReverse(payload);
+ break;
+ case ROOM_CREATE: // unused
+ break;
+ case ROOM_JOIN:
+ processRoomAction(payload);
+ break;
+ case ROOM_LEAVE:
+ processRoomAction(payload);
+ break;
+ case SYNC_CLIENT:
+ processRoomAction(payload);
+ break;
+ case ROOM_LIST:
+ processRoomsList(payload);
+ break;
+ case PayloadType.READY:
+ processReadyStatus(payload, false);
+ break;
+ case PayloadType.SYNC_READY:
+ processReadyStatus(payload, true);
+ break;
+ case PayloadType.RESET_READY:
+ // no payload body required; this acts purely as a reset signal
+ processResetReady();
+ break;
+ case PayloadType.PHASE:
+ processPhase(payload);
+ break;
+ case PayloadType.TURN:
+ case PayloadType.SYNC_TURN:
+ processTurn(payload);
+ break;
+ case PayloadType.RESET_TURN:
+ // no extra data required; this is purely a reset trigger
+ processResetTurn();
+ break;
+ case PayloadType.POINTS:
+ processPoints(payload);
+ break;
+ default:
+ LoggerUtil.INSTANCE.warning(TextFX.colorize("Unhandled payload type", Color.YELLOW));
+ break;
+
+ }
+ }
+
+ // Begin process*() handler methods
+ private void processResetTurn() {
+ knownClients.values().forEach(cp -> cp.setTookTurn(false));
+ System.out.println("Turn status reset for everyone");
+ }
+
+ private void processTurn(Payload payload) {
+ // Note: Currently assuming ReadyPayload (may be replaced with a custom payload later)
+ if (!(payload instanceof ReadyPayload)) {
+ error("Invalid payload subclass for processTurn");
+ return;
+ }
+ ReadyPayload rp = (ReadyPayload) payload;
+ if (!knownClients.containsKey(rp.getClientId())) {
+ LoggerUtil.INSTANCE.severe(String.format("Received turn status for client id %s who is not known",
+ rp.getClientId()));
+ return;
+ }
+ User cp = knownClients.get(rp.getClientId());
+ cp.setTookTurn(rp.isReady());
+ if (payload.getPayloadType() != PayloadType.SYNC_TURN) {
+ String message = String.format("%s %s their turn", cp.getDisplayName(),
+ cp.didTakeTurn() ? "took" : "reset");
+ LoggerUtil.INSTANCE.info(message);
+ }
+
+ }
+
+ private void processPhase(Payload payload) {
+ currentPhase = Enum.valueOf(Phase.class, payload.getMessage());
+ System.out.println(TextFX.colorize("Current phase is " + currentPhase.name(), Color.YELLOW));
+ }
+
+ private void processResetReady() {
+ knownClients.values().forEach(cp -> cp.setReady(false));
+ System.out.println("Ready status reset for everyone");
+ }
+
+ private void processReadyStatus(Payload payload, boolean isQuiet) {
+ if (!(payload instanceof ReadyPayload)) {
+ error("Invalid payload subclass for processRoomsList");
+ return;
+ }
+ ReadyPayload rp = (ReadyPayload) payload;
+ if (!knownClients.containsKey(rp.getClientId())) {
+ LoggerUtil.INSTANCE.severe(String.format("Received ready status [%s] for client id %s who is not known",
+ rp.isReady() ? "ready" : "not ready", rp.getClientId()));
+ return;
+ }
+ User cp = knownClients.get(rp.getClientId());
+ cp.setReady(rp.isReady());
+ if (!isQuiet) {
+ System.out.println(
+ String.format("%s is %s", cp.getDisplayName(),
+ rp.isReady() ? "ready" : "not ready"));
+ }
+ }
+
+ private void processRoomsList(Payload payload) {
+ if (!(payload instanceof RoomResultPayload)) {
+ error("Invalid payload subclass for processRoomsList");
+ return;
+ }
+ RoomResultPayload rrp = (RoomResultPayload) payload;
+ List rooms = rrp.getRooms();
+ if (rooms == null || rooms.size() == 0) {
+ LoggerUtil.INSTANCE.warning(
+ TextFX.colorize("No rooms found matching your query",
+ Color.RED));
+ return;
+ }
+ LoggerUtil.INSTANCE.info(TextFX.colorize("Room Results:", Color.PURPLE));
+ LoggerUtil.INSTANCE.info(
+ String.join(System.lineSeparator(), rooms));
+ }
+
+ private void processClientData(Payload payload) {
+ if (myUser.getClientId() != Constants.DEFAULT_CLIENT_ID) {
+ LoggerUtil.INSTANCE.warning(TextFX.colorize("Client ID already set, this shouldn't happen", Color.YELLOW));
+
+ }
+ myUser.setClientId(payload.getClientId());
+ myUser.setClientName(((ConnectionPayload) payload).getClientName()); // confirmation from Server
+ knownClients.put(myUser.getClientId(), myUser);
+ LoggerUtil.INSTANCE.info(TextFX.colorize("Connected", Color.GREEN));
+ }
+
+ private void processDisconnect(Payload payload) {
+ if (payload.getClientId() == myUser.getClientId()) {
+ knownClients.clear();
+ myUser.reset();
+ LoggerUtil.INSTANCE.info(TextFX.colorize("You disconnected", Color.RED));
+ } else if (knownClients.containsKey(payload.getClientId())) {
+ User disconnectedUser = knownClients.remove(payload.getClientId());
+ if (disconnectedUser != null) {
+ LoggerUtil.INSTANCE
+ .info(TextFX.colorize(String.format("%s disconnected", disconnectedUser.getDisplayName()),
+ Color.RED));
+ }
+ }
+
+ }
+
+ private void processRoomAction(Payload payload) {
+ if (!(payload instanceof ConnectionPayload)) {
+ error("Invalid payload subclass for processRoomAction");
+ return;
+ }
+ ConnectionPayload connectionPayload = (ConnectionPayload) payload;
+ // use DEFAULT_CLIENT_ID to clear knownClients (typically on disconnect or when changing rooms)
+ if (connectionPayload.getClientId() == Constants.DEFAULT_CLIENT_ID) {
+ knownClients.clear();
+ return;
+ }
+ switch (connectionPayload.getPayloadType()) {
+
+ case ROOM_LEAVE:
+ // remove departing user from the tracking map
+ if (knownClients.containsKey(connectionPayload.getClientId())) {
+ knownClients.remove(connectionPayload.getClientId());
+ }
+ if (connectionPayload.getMessage() != null) {
+ LoggerUtil.INSTANCE.info(TextFX.colorize(connectionPayload.getMessage(), Color.YELLOW));
+ }
+
+ break;
+ case ROOM_JOIN:
+ if (connectionPayload.getMessage() != null) {
+ LoggerUtil.INSTANCE.info(TextFX.colorize(connectionPayload.getMessage(), Color.GREEN));
+ }
+ // fall-through to keep the client list synchronized
+ case SYNC_CLIENT:
+ // add or update client information in the map
+ if (!knownClients.containsKey(connectionPayload.getClientId())) {
+ User user = new User();
+ user.setClientId(connectionPayload.getClientId());
+ user.setClientName(connectionPayload.getClientName());
+ knownClients.put(connectionPayload.getClientId(), user);
+ }
+ break;
+ default:
+ error("Invalid payload type for processRoomAction");
+ break;
+ }
+ }
+
+ private void processMessage(Payload payload) {
+ LoggerUtil.INSTANCE.info(TextFX.colorize(payload.getMessage(), Color.BLUE));
+ }
+
+ private void processReverse(Payload payload) {
+ LoggerUtil.INSTANCE.info(TextFX.colorize(payload.getMessage(), Color.PURPLE));
+ }
+
+ private void processPoints(Payload payload) {
+ if (!(payload instanceof PointsPayload)) {
+ error("Invalid payload subclass for processPoints");
+ return;
+ }
+ PointsPayload pp = (PointsPayload) payload;
+ long id = pp.getClientId();
+ int pts = pp.getPoints();
+ if (!knownClients.containsKey(id)) {
+ // create a placeholder entry if the user isn't already tracked
+ User user = new User();
+ user.setClientId(id);
+ knownClients.put(id, user);
+ }
+ User u = knownClients.get(id);
+ u.setPoints(pts);
+ System.out.println(String.format("%s has %d points", u.getDisplayName(), pts));
+ }
+ // End process*() handler methods
+
+ /**
+ * Watches for keyboard input from the local user and forwards
+ * it either as commands or chat messages to the server.
+ */
+ private void listenToInput() {
+ try (Scanner si = new Scanner(System.in)) {
+ LoggerUtil.INSTANCE.info("Waiting for input"); // placed here to avoid repeated log spam
+ while (isRunning) { // continue loop until isRunning is flipped to false
+ String userInput = si.nextLine();
+ if (!processClientCommand(userInput)) {
+ sendMessage(userInput);
+ }
+ }
+ } catch (IOException ioException) {
+ LoggerUtil.INSTANCE.severe("Error in listenToInput()", ioException);
+ // ioException.printStackTrace();
+ }
+ LoggerUtil.INSTANCE.info("listenToInput thread stopped");
+ }
+
+ /**
+ * Shuts down the client and cleans up all related resources.
+ */
+ private void close() {
+ isRunning = false;
+ closeServerConnection();
+ LoggerUtil.INSTANCE.info("Client terminated");
+ // System.exit(0); // Optionally terminate the entire application
+ }
+
+ /**
+ * Closes the connection to the server along with input/output streams.
+ */
+ private void closeServerConnection() {
+ try {
+ if (out != null) {
+ LoggerUtil.INSTANCE.info("Closing output stream");
+ out.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (in != null) {
+ LoggerUtil.INSTANCE.info("Closing input stream");
+ in.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ try {
+ if (server != null) {
+ LoggerUtil.INSTANCE.info("Closing connection");
+ server.close();
+ LoggerUtil.INSTANCE.info("Closed Socket");
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ // LoggerUtil.INSTANCE.severe("Socket Error", e);
+ }
+ }
+
+ public static void main(String[] args) {
+ Client client = Client.INSTANCE;
+ try {
+ client.start();
+ } catch (IOException e) {
+ System.out.println("Exception from main()");
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/Project/Common/Command.java b/Project/Common/Command.java
new file mode 100644
index 0000000..ea455ed
--- /dev/null
+++ b/Project/Common/Command.java
@@ -0,0 +1,36 @@
+package Project.Common;
+
+import java.util.HashMap;
+
+public enum Command {
+ QUIT("quit"),
+ DISCONNECT("disconnect"),
+ LOGOUT("logout"),
+ LOGOFF("logoff"),
+ REVERSE("reverse"),
+ CREATE_ROOM("createroom"),
+ LEAVE_ROOM("leaveroom"),
+ JOIN_ROOM("joinroom"),
+ NAME("name"),
+ LIST_USERS("users"),
+ LIST_ROOMS("listrooms"),
+ READY("ready"),
+ SCOREBOARD("scoreboard"),
+ EXAMPLE_TURN("exampleturn"),;
+
+ private static final HashMap BY_COMMAND = new HashMap<>();
+ static {
+ for (Command e : values()) {
+ BY_COMMAND.put(e.command, e);
+ }
+ }
+ public final String command;
+
+ private Command(String command) {
+ this.command = command;
+ }
+
+ public static Command stringToCommand(String command) {
+ return BY_COMMAND.get(command);
+ }
+}
\ No newline at end of file
diff --git a/Project/Common/ConnectionPayload.java b/Project/Common/ConnectionPayload.java
new file mode 100644
index 0000000..9a7dd27
--- /dev/null
+++ b/Project/Common/ConnectionPayload.java
@@ -0,0 +1,32 @@
+
+package Project.Common;
+
+public class ConnectionPayload extends Payload {
+ private String clientName;
+
+ /**
+ * @return the clientName
+ */
+ public String getClientName() {
+ return clientName;
+ }
+
+ /**
+ * @param clientName the clientName to set
+ */
+ public void setClientName(String clientName) {
+ this.clientName = clientName;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() +
+ String.format(" ClientName: [%s]",
+ getClientName());
+ }
+
+}
+// rk975/11/26/25
+//Overrides toString() to add extra information to the parent class's string output.
+//Calls super.toString() to include base class details.
+//Appends the client’s name in a formatted ClientName: [name] section.
\ No newline at end of file
diff --git a/Project/Common/Constants.java b/Project/Common/Constants.java
new file mode 100644
index 0000000..285ab1e
--- /dev/null
+++ b/Project/Common/Constants.java
@@ -0,0 +1,8 @@
+
+package Project.Common;
+
+public abstract class Constants {
+ final public static String COMMAND_TRIGGER = "/";
+ final public static String SINGLE_SPACE = " ";
+ final public static long DEFAULT_CLIENT_ID = -1;
+}
diff --git a/Project/Common/LoggerUtil.java b/Project/Common/LoggerUtil.java
new file mode 100644
index 0000000..190779a
--- /dev/null
+++ b/Project/Common/LoggerUtil.java
@@ -0,0 +1,612 @@
+package Project.Common;
+
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.FileHandler;
+import java.util.logging.Formatter;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+/**
+ * Helper utility for writing log output to files and the console.
+ * Provides convenience methods for logging at different levels
+ * and coordinates thread-safe access to the underlying logger.
+ */
+public enum LoggerUtil {
+ INSTANCE;
+
+ private Logger logger;
+ private LoggerConfig config;
+ private boolean isConfigured = false;
+
+ LoggerUtil() {
+ }
+
+ /**
+ * Applies the given logger configuration.
+ *
+ * @param config the LoggerConfig instance containing all logger settings
+ */
+ public void setConfig(LoggerConfig config) {
+ this.config = config;
+ setupLogger();
+ }
+
+ /**
+ * Formatter implementation that controls the layout of log entries.
+ * Adds timestamp, log level, originating class, and the formatted message.
+ */
+ private static class CustomFormatter extends Formatter {
+ private static final String PATTERN = "MM/dd/yyyy HH:mm:ss";
+ private static final String RESET = "\u001B[0m";
+ private static final String RED = "\u001B[31m";
+ private static final String GREEN = "\u001B[32m";
+ private static final String YELLOW = "\u001B[33m";
+ private static final String BLUE = "\u001B[34m";
+ private static final String PURPLE = "\u001B[35m";
+ private static final String CYAN = "\u001B[36m";
+ private static final String WHITE = "\u001B[37m";
+
+ @Override
+ public String format(LogRecord record) {
+ SimpleDateFormat dateFormat = new SimpleDateFormat(PATTERN);
+ String date = dateFormat.format(new Date(record.getMillis()));
+ String callingClass = getCallingClassName();
+ String source = callingClass != null ? callingClass
+ : record.getSourceClassName() != null ? record.getSourceClassName() : "unknown";
+
+ String message = formatMessage(record);
+ if (message == null)
+ message = "null";
+ String level = getColoredLevel(record.getLevel());
+ String throwable = "";
+ if (record.getThrown() != null) {
+ // Use stackTraceLimit from LoggerConfig to shorten the printed stack trace
+ throwable = "\n"
+ + getFormattedStackTrace(record.getThrown(), LoggerUtil.INSTANCE.config.getStackTraceLimit());
+ }
+ return String.format("%s [%s] (%s):\n> %s%s\n", date, source, level, message, throwable);
+ }
+
+ /**
+ * Attempts to determine the external class that invoked the logger.
+ *
+ * @return the fully-qualified name of the caller class, or null if not found
+ */
+ private static String getCallingClassName() {
+ String loggerUtilPackage = LoggerUtil.class.getPackage().getName();
+ StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
+ for (StackTraceElement element : stackTrace) {
+ String className = element.getClassName();
+ // Skip entries from the Java logging framework, LoggerUtil's own package,
+ // and the Thread class to locate the actual caller.
+ if (!className.startsWith("java.util.logging") &&
+ !className.startsWith(loggerUtilPackage) &&
+ !className.equals(Thread.class.getName())) {
+ return className;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Wraps the log level name in an ANSI color code based on severity.
+ *
+ * @param level the logging Level
+ * @return a string representing the colored log level
+ */
+ private static String getColoredLevel(Level level) {
+ switch (level.getName()) {
+ case "SEVERE":
+ return RED + level.getName() + RESET;
+ case "WARNING":
+ return YELLOW + level.getName() + RESET;
+ case "INFO":
+ return GREEN + level.getName() + RESET;
+ case "CONFIG":
+ return CYAN + level.getName() + RESET;
+ case "FINE":
+ return BLUE + level.getName() + RESET;
+ case "FINER":
+ return PURPLE + level.getName() + RESET;
+ case "FINEST":
+ return WHITE + level.getName() + RESET;
+ default:
+ return level.getName();
+ }
+ }
+
+ /**
+ * Builds a string representation of a Throwable's stack trace.
+ * It includes the exception type, message, and up to maxElements of the stack.
+ *
+ * @param throwable the Throwable to inspect
+ * @param maxElements maximum number of stack frames to output
+ * @return a nicely formatted stack trace string
+ */
+ private static String getFormattedStackTrace(Throwable throwable, int maxElements) {
+ StringBuilder sb = new StringBuilder();
+
+ // Add the exception class name and message details
+ sb.append(throwable.getClass().getName());
+ if (throwable.getMessage() != null) {
+ sb.append(": ").append(throwable.getMessage());
+ }
+ sb.append("\n");
+
+ // Output stack frames up to the configured limit
+ StackTraceElement[] stackTrace = throwable.getStackTrace();
+ int length = stackTrace.length;
+ int displayLimit = Math.min(maxElements, length);
+
+ for (int i = 0; i < displayLimit; i++) {
+ sb.append("\tat ").append(stackTrace[i]).append("\n");
+ }
+
+ if (length > maxElements) {
+ sb.append("\t... ").append(length - maxElements).append(" more elements truncated ...\n");
+ }
+
+ // Recursively include any suppressed exceptions
+ for (Throwable suppressed : throwable.getSuppressed()) {
+ sb.append("Suppressed: ").append(getFormattedStackTrace(suppressed, maxElements));
+ }
+
+ // Recursively include the root cause chain
+ Throwable cause = throwable.getCause();
+ if (cause != null && cause != throwable) {
+ sb.append("Caused by: ").append(getFormattedStackTrace(cause, maxElements));
+ }
+
+ return sb.toString();
+ }
+
+ }
+
+ /**
+ * Initializes and configures the logger once.
+ * Subsequent calls are ignored after the first successful setup.
+ */
+ private synchronized void setupLogger() {
+ if (isConfigured) {
+ return;
+ }
+ if (config == null) {
+ throw new IllegalStateException("LoggerUtil configuration must be set before use.");
+ }
+ try {
+ logger = Logger.getLogger("ApplicationLogger");
+
+ // Strip existing default console handlers from the root logger
+ Logger rootLogger = Logger.getLogger("");
+ for (var handler : rootLogger.getHandlers()) {
+ rootLogger.removeHandler(handler);
+ }
+
+ // Generate the log file pattern with index suffix support for rotation
+ String logPattern = config.getLogLocation().replace(".log", "-%g.log");
+ // FileHandler writes logs to disk, with rollover supported based on size/count
+ FileHandler fileHandler = new FileHandler(
+ logPattern,
+ config.getFileSizeLimit(),
+ config.getFileCount(),
+ true);
+ fileHandler.setFormatter(new CustomFormatter());
+ fileHandler.setLevel(config.getFileLogLevel());
+ logger.addHandler(fileHandler);
+
+ // ConsoleHandler outputs log entries to stdout/stderr
+ ConsoleHandler consoleHandler = new ConsoleHandler();
+ consoleHandler.setFormatter(new CustomFormatter());
+ consoleHandler.setLevel(config.getConsoleLogLevel());
+ logger.addHandler(consoleHandler);
+
+ logger.setLevel(Level.ALL);
+ isConfigured = true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Generic logging method that logs a text message at the given level.
+ *
+ * @param level the severity level
+ * @param message the text to write to the log
+ */
+ public void log(Level level, String message) {
+ if (!isConfigured)
+ setupLogger();
+ logger.log(level, message);
+ }
+
+ /**
+ * Overloaded logging method that accepts an Object.
+ *
+ * - If the Object is a String, logs it directly.
+ * - If it's a Throwable, logs both its message and stack trace.
+ * - Otherwise, logs the result of message.toString().
+ *
+ *
+ * @param level the severity level
+ * @param message arbitrary object to be logged
+ */
+ public void log(Level level, Object message) {
+ if (!isConfigured) {
+ setupLogger();
+ }
+
+ if (message instanceof String) {
+ logger.log(level, (String) message);
+
+ } else if (message instanceof Throwable) {
+ Throwable t = (Throwable) message;
+ String msg = (t.getMessage() != null) ? t.getMessage() : t.getClass().getName();
+ logger.log(level, msg, t);
+
+ } else if (message != null) {
+ try {
+ logger.log(level, message.toString());
+ } catch (Exception ex) {
+ logger.log(level, "Error during toString(): " + ex.getMessage(), ex);
+ }
+
+ } else {
+ logger.log(level, "null");
+ }
+ }
+
+ /**
+ * Logs an exception or error along with a message at the specified level.
+ *
+ * @param level the severity level
+ * @param message context message to accompany the exception
+ * @param throwable the Throwable being recorded
+ */
+ public void log(Level level, String message, Throwable throwable) {
+ if (!isConfigured)
+ setupLogger();
+ logger.log(level, message, throwable);
+ }
+
+ /**
+ * Convenience method for INFO-level logging.
+ *
+ * @param message the message to write
+ */
+ public void info(String message) {
+ log(Level.INFO, message);
+ }
+
+ /**
+ * INFO-level logging overload that accepts any Object.
+ *
+ * @param message the object to log
+ */
+ public void info(Object message) {
+ log(Level.INFO, message);
+ }
+
+ /**
+ * Logs an INFO-level message along with a Throwable.
+ *
+ * @param message the context message
+ * @param throwable the associated exception or error
+ */
+ public void info(String message, Throwable throwable) {
+ log(Level.INFO, message, throwable);
+ }
+
+ /**
+ * Convenience wrapper for WARNING-level logs.
+ *
+ * @param message the warning text
+ */
+ public void warning(String message) {
+ log(Level.WARNING, message);
+ }
+
+ /**
+ * WARNING-level variant that accepts a generic Object.
+ *
+ * @param message object to be rendered in the log
+ */
+ public void warning(Object message) {
+ log(Level.WARNING, message);
+ }
+
+ /**
+ * Logs a WARNING-level message together with a Throwable.
+ *
+ * @param message explanatory message
+ * @param throwable related exception
+ */
+ public void warning(String message, Throwable throwable) {
+ log(Level.WARNING, message, throwable);
+ }
+
+ /**
+ * Convenience API for logging critical failures at SEVERE level.
+ *
+ * @param message description of the error condition
+ */
+ public void severe(String message) {
+ log(Level.SEVERE, message);
+ }
+
+ /**
+ * SEVERE-level overload for arbitrary objects.
+ *
+ * @param message the object whose contents should be logged
+ */
+ public void severe(Object message) {
+ log(Level.SEVERE, message);
+ }
+
+ /**
+ * Logs a SEVERE-level entry with a Throwable and message.
+ *
+ * @param message context or description
+ * @param throwable the underlying cause to be recorded
+ */
+ public void severe(String message, Throwable throwable) {
+ log(Level.SEVERE, message, throwable);
+ }
+
+ /**
+ * Logs at FINE level, typically for detailed informational output.
+ *
+ * @param message the message payload
+ */
+ public void fine(String message) {
+ log(Level.FINE, message);
+ }
+
+ /**
+ * FINE-level logging of an arbitrary Object.
+ *
+ * @param message the object to be converted and logged
+ */
+ public void fine(Object message) {
+ log(Level.FINE, message);
+ }
+
+ /**
+ * Logs at FINER level for more granular debugging information.
+ *
+ * @param message the detail message
+ */
+ public void finer(String message) {
+ log(Level.FINER, message);
+ }
+
+ /**
+ * FINER-level logging variant that works with an Object.
+ *
+ * @param message object whose string form will be logged
+ */
+ public void finer(Object message) {
+ log(Level.FINER, message);
+ }
+
+ /**
+ * Logs at the FINEST level for extremely detailed diagnostic output.
+ *
+ * @param message descriptive message text
+ */
+ public void finest(String message) {
+ log(Level.FINEST, message);
+ }
+
+ /**
+ * FINEST-level method that can accept any Object.
+ *
+ * @param message object to serialize into the log
+ */
+ public void finest(Object message) {
+ log(Level.FINEST, message);
+ }
+
+ /**
+ * Configuration holder for LoggerUtil.
+ * Encapsulates all tuning parameters for both file and console logging.
+ */
+ public static class LoggerConfig {
+ private int fileSizeLimit = 1024 * 1024; // 1MB default maximum size per file
+ private int fileCount = 5; // default number of rotated log files
+ private String logLocation = "application.log";
+ private Level fileLogLevel = Level.ALL; // default file logging threshold
+ private Level consoleLogLevel = Level.ALL; // default console logging threshold
+ private int stackTraceLimit = 10; // default limit for stack trace depth
+
+ // Getters and Setters for each property
+
+ /**
+ * Returns the size threshold for individual log files.
+ *
+ * @return maximum allowed size in bytes for one log file
+ */
+ public int getFileSizeLimit() {
+ return fileSizeLimit;
+ }
+
+ /**
+ * Updates the size cap for each log file in the rotation set.
+ *
+ * @param fileLimit new file size limit in bytes
+ */
+ public void setFileSizeLimit(int fileLimit) {
+ this.fileSizeLimit = fileLimit;
+ }
+
+ /**
+ * Retrieves how many log files are maintained in the rotation.
+ *
+ * @return number of log files used for rotation
+ */
+ public int getFileCount() {
+ return fileCount;
+ }
+
+ /**
+ * Specifies how many rolling log files should be kept.
+ *
+ * @param fileCount desired number of log files in the cycle
+ */
+ public void setFileCount(int fileCount) {
+ this.fileCount = fileCount;
+ }
+
+ /**
+ * Gets the base path/filename for the log output.
+ *
+ * @return path string for the log file destination
+ */
+ public String getLogLocation() {
+ return logLocation;
+ }
+
+ /**
+ * Sets the base file path used by the FileHandler.
+ *
+ * @param logLocation the log file name or path
+ */
+ public void setLogLocation(String logLocation) {
+ this.logLocation = logLocation;
+ }
+
+ /**
+ * Retrieves the logging Level applied to file output.
+ *
+ * @return file logging threshold
+ */
+ public Level getFileLogLevel() {
+ return fileLogLevel;
+ }
+
+ /**
+ * Adjusts the minimum Level of messages written to file.
+ *
+ * @param fileLogLevel new file logging level
+ */
+ public void setFileLogLevel(Level fileLogLevel) {
+ this.fileLogLevel = fileLogLevel;
+ }
+
+ /**
+ * Retrieves the logging Level used for console output.
+ *
+ * @return console logging Level
+ */
+ public Level getConsoleLogLevel() {
+ return consoleLogLevel;
+ }
+
+ /**
+ * Sets the minimum severity for messages printed to the console.
+ *
+ * @param consoleLogLevel desired console logging Level
+ */
+ public void setConsoleLogLevel(Level consoleLogLevel) {
+ this.consoleLogLevel = consoleLogLevel;
+ }
+
+ /**
+ * Returns the current maximum stack frames shown in logged traces.
+ *
+ * @return stack trace length cap
+ */
+ public int getStackTraceLimit() {
+ return stackTraceLimit;
+ }
+
+ /**
+ * Configures how many stack trace elements are included when logging errors.
+ *
+ * @param stackTraceLimit upper bound for stack frames to output
+ */
+ public void setStackTraceLimit(int stackTraceLimit) {
+ this.stackTraceLimit = stackTraceLimit;
+ }
+ }
+
+ /**
+ * Simple demonstration entry point.
+ *
+ * @param args command-line arguments (unused)
+ */
+ public static void main(String[] args) {
+ // Build a LoggerConfig instance and adjust sample settings
+ LoggerUtil.LoggerConfig config = new LoggerUtil.LoggerConfig();
+ config.setFileSizeLimit(2048 * 1024); // adjust file size cap to 2MB
+ config.setFileCount(10); // maintain 10 rotated log files
+ config.setLogLocation("example.log"); // sample log file name
+ config.setFileLogLevel(Level.ALL); // capture all messages to file
+ config.setConsoleLogLevel(Level.ALL); // show all messages on console
+
+ // Register the logger configuration
+ LoggerUtil.INSTANCE.setConfig(config);
+
+ // Original examples
+ LoggerUtil.INSTANCE.info("This is an info message.");
+ LoggerUtil.INSTANCE.warning("This is a warning message.");
+ LoggerUtil.INSTANCE.severe("This is a severe error message.");
+ LoggerUtil.INSTANCE.fine("This is a fine-grained informational message.");
+ LoggerUtil.INSTANCE.finer("This is a finer-grained informational message.");
+ LoggerUtil.INSTANCE.finest("This is the finest-grained informational message.");
+
+ // Demonstrate logging from a separate thread context
+ new Thread(() -> {
+ LoggerUtil.INSTANCE.info("This is a message from a separate thread.");
+ }).start();
+
+ // Try logging exceptions at different levels
+ LoggerUtil.INSTANCE.warning("This is a simulated warning exception.", new IOException("Simulated IOException"));
+ LoggerUtil.INSTANCE.severe("This is a simulated severe error", new Exception("Simulated Exception"));
+
+ // New examples to test Object overloads
+ LoggerUtil.INSTANCE.info(new Object() {
+ @Override
+ public String toString() {
+ return "Logging a custom object using info";
+ }
+ });
+
+ LoggerUtil.INSTANCE.warning(new Exception("Logging a Throwable object using warning"));
+
+ LoggerUtil.INSTANCE.severe(new Object() {
+ @Override
+ public String toString() {
+ return "Logging a custom object using severe";
+ }
+ });
+
+ LoggerUtil.INSTANCE.fine(new Object() {
+ @Override
+ public String toString() {
+ return "Logging a custom object using fine";
+ }
+ });
+
+ // Log a null reference to cover edge behavior
+ try {
+ LoggerUtil.INSTANCE.info((Object) null);
+ } catch (Exception e) {
+ LoggerUtil.INSTANCE.severe("A NullPointerException occurred!", e);
+ }
+ // Example to deliberately trigger a deeper stack trace (StackOverflowError)
+ try {
+ recursiveMethod(0);
+ } catch (StackOverflowError e) {
+ LoggerUtil.INSTANCE.severe("A StackOverflowError occurred!", e);
+ }
+ }
+
+ private static void recursiveMethod(int depth) {
+ // Recursively call itself until the stack overflows
+ recursiveMethod(depth + 1);
+ }
+}
diff --git a/Project/Common/Payload.java b/Project/Common/Payload.java
new file mode 100644
index 0000000..a02aa3d
--- /dev/null
+++ b/Project/Common/Payload.java
@@ -0,0 +1,59 @@
+
+package Project.Common;
+
+import java.io.Serializable;
+
+public class Payload implements Serializable {
+ private PayloadType payloadType;
+ private long clientId;
+ private String message;
+
+ /**
+ * @return the payloadType
+ */
+ public PayloadType getPayloadType() {
+ return payloadType;
+ }
+
+ /**
+ * @param payloadType the payloadType to set
+ */
+ public void setPayloadType(PayloadType payloadType) {
+ this.payloadType = payloadType;
+ }
+
+ /**
+ * @return the clientId
+ */
+ public long getClientId() {
+ return clientId;
+ }
+
+ /**
+ * @param clientId the clientId to set
+ */
+ public void setClientId(long clientId) {
+ this.clientId = clientId;
+ }
+
+ /**
+ * @return the message
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * @param message the message to set
+ */
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Payload[%s] Client Id [%s] Message: [%s]", getPayloadType(), getClientId(), getMessage());
+ }
+}
+// rk975 - 11/26/25
+// Overrides toString() to provide a readable text version of the object.
\ No newline at end of file
diff --git a/Project/Common/PayloadType.java b/Project/Common/PayloadType.java
new file mode 100644
index 0000000..7a3d475
--- /dev/null
+++ b/Project/Common/PayloadType.java
@@ -0,0 +1,26 @@
+package Project.Common;
+
+public enum PayloadType {
+ CLIENT_CONNECT, // client initiating a connection to the server (sending initial data like name)
+ CLIENT_ID, // server providing a unique identifier back to the client
+ SYNC_CLIENT, // quietly updates/synchronizes the list of clients in a room
+ DISCONNECT, // explicit request to disconnect from the server
+ ROOM_CREATE, // request to create a new room
+ ROOM_JOIN, // request to join an existing room
+ ROOM_LEAVE, // request to leave the current room
+ REVERSE, // reverse-text style operation
+ MESSAGE, // standard chat payload containing sender info and message text
+ ROOM_LIST, // response or request that involves listing available rooms
+ READY, // client signaling they are ready; server uses it to update one client's ready status
+ SYNC_READY, // non-verbose READY update, used to synchronize ready flags for clients in a GameRoom
+ RESET_READY, // instruction to reset all local ready flags on the client side (reduces extra network calls)
+ PHASE, // communicates the current phase/state of the game session (used as a gate for allowed actions)
+ TURN, // indicates a player has taken a turn and that action should be propagated
+ SYNC_TURN, // silent TURN update, used to align each client's view of who has taken a turn in a GameRoom
+ RESET_TURN, // directive for clients to clear local turn-tracking state
+ PICK, // client selection for rock/paper/scissors choice
+ POINTS, // server update with current player score information
+ ROUND_START, // notification that a new round has begun
+ ROUND_END, // notification that the current round has concluded
+ SCOREBOARD, // request or response for overall scoring/leaderboard data
+}
diff --git a/Project/Common/Phase.java b/Project/Common/Phase.java
new file mode 100644
index 0000000..d46fba8
--- /dev/null
+++ b/Project/Common/Phase.java
@@ -0,0 +1,7 @@
+package Project.Common;
+
+public enum Phase {
+ READY, // pre-setup phase
+ CHOOSING, // players pick their choice
+ IN_PROGRESS, // example phase that'll be renamed/removed later
+}
\ No newline at end of file
diff --git a/Project/Common/PickPayload.java b/Project/Common/PickPayload.java
new file mode 100644
index 0000000..2d18cbe
--- /dev/null
+++ b/Project/Common/PickPayload.java
@@ -0,0 +1,23 @@
+package Project.Common;
+
+public class PickPayload extends Payload {
+ private String choice; // "r", "p", or "s"
+
+ public String getChoice() {
+ return choice;
+ }
+
+ public void setChoice(String choice) {
+ this.choice = choice;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("PickPayload{clientId=%d, choice=%s, type=%s}", getClientId(), choice,
+ getPayloadType());
+ }
+}
+// rk975 - 11/26/25
+// Overrides toString() to provide a readable text version of the object.
+// Uses String.format() to include clientId, choice, and the payload type.
+//Helps with debugging and logging by showing object data in one line.
\ No newline at end of file
diff --git a/Project/Common/PointsPayload.java b/Project/Common/PointsPayload.java
new file mode 100644
index 0000000..a008c22
--- /dev/null
+++ b/Project/Common/PointsPayload.java
@@ -0,0 +1,23 @@
+package Project.Common;
+
+public class PointsPayload extends Payload {
+ private int points = 0;
+
+ public int getPoints() {
+ return points;
+ }
+
+ public void setPoints(int points) {
+ this.points = points;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("PointsPayload{clientId=%d, points=%d, type=%s}", getClientId(), points,
+ getPayloadType());
+ }
+}
+// rk975 - 11/26/25
+// Overrides toString() to provide a readable text version of the object.
+// Uses String.format() to include clientId, points, and the payload type.
+//Helps with debugging and logging by showing object data in one line.
\ No newline at end of file
diff --git a/Project/Common/ReadyPayload.java b/Project/Common/ReadyPayload.java
new file mode 100644
index 0000000..9e38888
--- /dev/null
+++ b/Project/Common/ReadyPayload.java
@@ -0,0 +1,26 @@
+package Project.Common;
+
+public class ReadyPayload extends Payload {
+ private boolean isReady;
+
+ public ReadyPayload() {
+ setPayloadType(PayloadType.READY);
+ }
+
+ public boolean isReady() {
+ return isReady;
+ }
+
+ public void setReady(boolean isReady) {
+ this.isReady = isReady;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + String.format(" isReady [%s]", isReady ? "ready" : "not ready");
+ }
+}
+// rk975 - 11/26/25
+// Overrides toString() to add readiness status to the base class's string output.
+// Calls super.toString() to include base class details.
+// Appends isReady status in a formatted isReady [ready/not ready] section.
\ No newline at end of file
diff --git a/Project/Common/RoomAction.java b/Project/Common/RoomAction.java
new file mode 100644
index 0000000..b61712b
--- /dev/null
+++ b/Project/Common/RoomAction.java
@@ -0,0 +1,5 @@
+package Project.Common;
+
+public enum RoomAction {
+ CREATE, JOIN, LEAVE, LIST
+}
\ No newline at end of file
diff --git a/Project/Common/RoomResultPayload.java b/Project/Common/RoomResultPayload.java
new file mode 100644
index 0000000..c1723cb
--- /dev/null
+++ b/Project/Common/RoomResultPayload.java
@@ -0,0 +1,29 @@
+package Project.Common;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class RoomResultPayload extends Payload {
+ private List rooms = new ArrayList();
+
+ public RoomResultPayload() {
+ setPayloadType(PayloadType.ROOM_LIST);
+ }
+
+ public List getRooms() {
+ return rooms;
+ }
+
+ public void setRooms(List rooms) {
+ this.rooms = rooms;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + "Rooms [" + String.join(",", rooms) + "]";
+ }
+}
+// rk975 - 11/26/25
+// Overrides toString() to add room list information to the base class's string output.
+// Calls super.toString() to include base class details.
+// Appends the list of rooms in a formatted Rooms [room1,room2,...] section.
\ No newline at end of file
diff --git a/Project/Common/TextFX.java b/Project/Common/TextFX.java
new file mode 100644
index 0000000..5c0c320
--- /dev/null
+++ b/Project/Common/TextFX.java
@@ -0,0 +1,73 @@
+
+package Project.Common;
+
+/**
+ * Utility to attempt to provide colored text in the terminal.
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+public abstract class TextFX {
+
+ /**
+ * TextFX.Color list of available colors
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ */
+ public enum Color {
+ BLACK("\033[0;30m"),
+ RED("\033[0;31m"),
+ GREEN("\033[0;32m"),
+ YELLOW("\033[0;33m"),
+ BLUE("\033[0;34m"),
+ PURPLE("\033[0;35m"),
+ CYAN("\033[0;36m"),
+ WHITE("\033[0;37m");
+
+ private final String code;
+
+ Color(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+ }
+
+ public static final String RESET = "\033[0m";
+
+ /**
+ * Generates a String with the original message wrapped in the ASCII of the
+ * color and RESET
+ *
+ *
+ * Note: May not work for all terminals
+ *
+ *
+ * Important: This does not satisfy the text formatting feature/requirement for
+ * chatroom projects.
+ *
+ *
+ * @param text Input text to colorize
+ * @param color Enum of Color choice from TextFX.Color
+ * @return wrapped String
+ */
+ public static String colorize(String text, Color color) {
+ StringBuilder builder = new StringBuilder();
+ builder.append(color.getCode());
+ builder.append(text);
+ builder.append(RESET);
+ return builder.toString();
+ }
+
+ public static void main(String[] args) {
+ // Example usage:
+ System.out.println(TextFX.colorize("Hello, world!", Color.RED));
+ System.out.println(TextFX.colorize("This is some blue text.", Color.BLUE));
+ System.out.println(TextFX.colorize("And this is green!", Color.GREEN));
+ }
+}
diff --git a/Project/Common/TimedEvent.java b/Project/Common/TimedEvent.java
new file mode 100644
index 0000000..eaef2cc
--- /dev/null
+++ b/Project/Common/TimedEvent.java
@@ -0,0 +1,112 @@
+package Project.Common;
+
+/* Initially inspired by https://gist.github.com/MattToegel/c55747f26c5092d6362678d5b1729ec6 */
+
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.function.Consumer;
+
+/**
+ * Lightweight countdown-style timer using java.util.Timer.
+ * Previously referred to as "Countdown".
+ */
+public class TimedEvent {
+ private int secondsRemaining;
+ private Runnable expireCallback = null;
+ private Consumer tickCallback = null;
+ final private Timer timer;
+
+ /**
+ * Builds a TimedEvent that will execute the provided callback once the
+ * specified time interval has elapsed.
+ *
+ * @param durationInSeconds number of seconds before expiration
+ * @param callback code to run when the timer finishes
+ */
+ public TimedEvent(int durationInSeconds, Runnable callback) {
+ this(durationInSeconds);
+ this.expireCallback = callback;
+ }
+
+ /**
+ * Constructs a TimedEvent that counts down for the given number of seconds.
+ * Note: you must assign an expireCallback and/or tickCallback, otherwise the
+ * timer will simply count down without triggering any actions.
+ *
+ * @param durationInSeconds initial countdown length in seconds
+ */
+ public TimedEvent(int durationInSeconds) {
+ timer = new Timer();
+ secondsRemaining = durationInSeconds;
+ timer.scheduleAtFixedRate(new TimerTask() {
+ public void run() {
+ secondsRemaining--;
+ if (tickCallback != null) {
+ tickCallback.accept(secondsRemaining);
+ }
+ if (secondsRemaining <= 0) {
+ timer.cancel();
+ secondsRemaining = 0;
+ if (expireCallback != null) {
+ expireCallback.run();
+ }
+ }
+ }
+ }, 1000, 1000);
+ }
+
+ /**
+ * Registers a callback to be invoked on every timer tick.
+ * The callback receives the current remaining time in seconds.
+ *
+ * @param callback function to call once per second with the current countdown value
+ */
+ public void setTickCallback(Consumer callback) {
+ tickCallback = callback;
+ }
+
+ /**
+ * Registers a callback to be executed when the countdown reaches zero.
+ *
+ * @param callback runnable to invoke when the timer expires
+ */
+ public void setExpireCallback(Runnable callback) {
+ expireCallback = callback;
+ }
+
+ /**
+ * Clears all callback references and halts the underlying timer.
+ */
+ public void cancel() {
+ expireCallback = null;
+ tickCallback = null;
+ timer.cancel();
+ }
+
+ /**
+ * Overrides the remaining countdown time in seconds.
+ *
+ * @param d new remaining duration, in seconds
+ */
+ public void setDurationInSeconds(int d) {
+ secondsRemaining = d;
+ }
+
+ public int getRemainingTime() {
+ return secondsRemaining;
+ }
+
+ /**
+ * Basic usage demonstration / sanity check.
+ *
+ * @param args ignored
+ */
+ public static void main(String args[]) {
+ TimedEvent cd = new TimedEvent(30, () -> {
+ System.out.println("Time expired");
+ });
+ cd.setTickCallback((tick) -> {
+ System.out.println("Tick: " + tick);
+ });
+ }
+}
\ No newline at end of file
diff --git a/Project/Common/User.java b/Project/Common/User.java
new file mode 100644
index 0000000..413a4f6
--- /dev/null
+++ b/Project/Common/User.java
@@ -0,0 +1,160 @@
+package Project.Common;
+
+public class User {
+ private long clientId = Constants.DEFAULT_CLIENT_ID;
+ private String clientName;
+ private boolean isReady = false;
+ private boolean tookTurn = false;
+ private int points = 0;
+ private boolean eliminated = false;
+ private String choice = null; // holds "r", "p", "s", or null depending on current selection
+
+ /**
+ * Returns the unique identifier assigned to this client.
+ *
+ * @return the clientId
+ */
+ public long getClientId() {
+ return clientId;
+ }
+
+ /**
+ * Updates the internal client identifier.
+ *
+ * @param clientId the id value to apply
+ */
+ public void setClientId(long clientId) {
+ this.clientId = clientId;
+ }
+
+ /**
+ * Retrieves the client's chosen display name.
+ *
+ * @return the clientName string
+ */
+ public String getClientName() {
+ return clientName;
+ }
+
+ /**
+ * Assigns a display name for this client.
+ *
+ * @param username the name to store
+ */
+ public void setClientName(String username) {
+ this.clientName = username;
+ }
+
+ /**
+ * Returns a formatted label combining name and ID for easy identification.
+ *
+ * @return a formatted string "name#id"
+ */
+ public String getDisplayName() {
+ return String.format("%s#%s", this.clientName, this.clientId);
+ }
+
+ /**
+ * Indicates whether this user has marked themselves as ready.
+ *
+ * @return true if ready, false otherwise
+ */
+ public boolean isReady() {
+ return isReady;
+ }
+
+ /**
+ * Toggles or sets the user's ready state.
+ *
+ * @param isReady new ready flag
+ */
+ public void setReady(boolean isReady) {
+ this.isReady = isReady;
+ }
+
+ /**
+ * Resets this user back to all default values.
+ * Useful after disconnecting or switching rooms.
+ */
+ public void reset() {
+ this.clientId = Constants.DEFAULT_CLIENT_ID;
+ this.clientName = null;
+ this.isReady = false;
+ this.tookTurn = false;
+ this.points = 0;
+ this.eliminated = false;
+ this.choice = null;
+ }
+
+ /**
+ * Retrieves the total points accumulated by this user.
+ *
+ * @return the current point count
+ */
+ public int getPoints() {
+ return points;
+ }
+
+ /**
+ * Updates the user's point total.
+ *
+ * @param points new score to assign
+ */
+ public void setPoints(int points) {
+ this.points = points;
+ }
+
+ /**
+ * Indicates whether this user has been removed or knocked out of a round.
+ *
+ * @return true if eliminated, false if still active
+ */
+ public boolean isEliminated() {
+ return eliminated;
+ }
+
+ /**
+ * Marks or unmarks the user as eliminated.
+ *
+ * @param eliminated elimination state to apply
+ */
+ public void setEliminated(boolean eliminated) {
+ this.eliminated = eliminated;
+ }
+
+ /**
+ * Retrieves the user's current selection (e.g., rock/paper/scissors).
+ *
+ * @return the stored choice value
+ */
+ public String getChoice() {
+ return choice;
+ }
+
+ /**
+ * Stores the user's chosen option (e.g., "r", "p", "s").
+ *
+ * @param choice the value to store
+ */
+ public void setChoice(String choice) {
+ this.choice = choice;
+ }
+
+ /**
+ * Identifies whether this user has already taken their turn.
+ *
+ * @return true if the turn has been taken
+ */
+ public boolean didTakeTurn() {
+ return tookTurn;
+ }
+
+ /**
+ * Sets the internal flag indicating the user's turn state.
+ *
+ * @param tookTurn true if the user has acted this turn
+ */
+ public void setTookTurn(boolean tookTurn) {
+ this.tookTurn = tookTurn;
+ }
+}
diff --git a/Project/Exceptions/CustomIT114Exception.java b/Project/Exceptions/CustomIT114Exception.java
new file mode 100644
index 0000000..fd49708
--- /dev/null
+++ b/Project/Exceptions/CustomIT114Exception.java
@@ -0,0 +1,12 @@
+
+package Project.Exceptions;
+
+public abstract class CustomIT114Exception extends Exception {
+ public CustomIT114Exception(String message) {
+ super(message);
+ }
+
+ public CustomIT114Exception(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/Project/Exceptions/DuplicateRoomException.java b/Project/Exceptions/DuplicateRoomException.java
new file mode 100644
index 0000000..a3eeea8
--- /dev/null
+++ b/Project/Exceptions/DuplicateRoomException.java
@@ -0,0 +1,12 @@
+package Project.Exceptions;
+
+public class DuplicateRoomException extends CustomIT114Exception {
+ public DuplicateRoomException(String message) {
+ super(message);
+ }
+
+ public DuplicateRoomException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
\ No newline at end of file
diff --git a/Project/Exceptions/NotReadyException.java b/Project/Exceptions/NotReadyException.java
new file mode 100644
index 0000000..d7267f6
--- /dev/null
+++ b/Project/Exceptions/NotReadyException.java
@@ -0,0 +1,12 @@
+
+package Project.Exceptions;
+
+public class NotReadyException extends CustomIT114Exception {
+ public NotReadyException(String message) {
+ super(message);
+ }
+
+ public NotReadyException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/Project/Exceptions/PhaseMismatchException.java b/Project/Exceptions/PhaseMismatchException.java
new file mode 100644
index 0000000..5c469ee
--- /dev/null
+++ b/Project/Exceptions/PhaseMismatchException.java
@@ -0,0 +1,12 @@
+package Project.Exceptions;
+
+public class PhaseMismatchException extends CustomIT114Exception {
+ public PhaseMismatchException(String message) {
+ super(message);
+ }
+
+ public PhaseMismatchException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
\ No newline at end of file
diff --git a/Project/Exceptions/PlayerNotFoundException.java b/Project/Exceptions/PlayerNotFoundException.java
new file mode 100644
index 0000000..92d9c65
--- /dev/null
+++ b/Project/Exceptions/PlayerNotFoundException.java
@@ -0,0 +1,12 @@
+package Project.Exceptions;
+
+public class PlayerNotFoundException extends CustomIT114Exception {
+ public PlayerNotFoundException(String message) {
+ super(message);
+ }
+
+ public PlayerNotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
\ No newline at end of file
diff --git a/Project/Exceptions/RoomNotFoundException.java b/Project/Exceptions/RoomNotFoundException.java
new file mode 100644
index 0000000..a558c2a
--- /dev/null
+++ b/Project/Exceptions/RoomNotFoundException.java
@@ -0,0 +1,14 @@
+
+package Project.Exceptions;
+
+public class RoomNotFoundException extends CustomIT114Exception {
+
+ public RoomNotFoundException(String message) {
+ super(message);
+ }
+
+ public RoomNotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/Project/Server/BaseGameRoom.java b/Project/Server/BaseGameRoom.java
new file mode 100644
index 0000000..6d56796
--- /dev/null
+++ b/Project/Server/BaseGameRoom.java
@@ -0,0 +1,331 @@
+package Project.Server;
+
+import Project.Common.Constants;
+import Project.Common.LoggerUtil;
+import Project.Common.Phase;
+import Project.Common.TimedEvent;
+import Project.Exceptions.NotReadyException;
+import Project.Exceptions.PhaseMismatchException;
+import Project.Exceptions.PlayerNotFoundException;
+
+/**
+ * Core base class that wires up the main game flow for a GameRoom.
+ * Concrete game rooms extend this and fill in the abstract lifecycle hooks.
+ */
+public abstract class BaseGameRoom extends Room {
+
+ private TimedEvent readyTimer = null;
+
+ // Minimum number of players that must be ready for a session to start
+ protected final int MINIMUM_REQUIRED_TO_START = 2;
+
+ protected Phase currentPhase = Phase.READY;
+
+ // If true, /ready can be used to toggle; if false, it only sets to ready
+ protected boolean allowToggleReady = false;
+
+ public BaseGameRoom(String name) {
+ super(name);
+ }
+
+ /**
+ * Called once at the beginning of the game session (kicked off via readyCheck).
+ */
+ protected abstract void onSessionStart();
+ // rk975 - 11/26/25
+ // Initializes game session state, resets round counter,
+ // and transitions to the CHOOSING phase to start the first round.
+
+ /**
+ * Invoked when a new round begins.
+ * In simpler games this may be used instead of per-turn logic.
+ */
+ protected abstract void onRoundStart();
+ // rk975 - 11/26/25
+ // Advances the round counter, resets per-round state,
+ // and transitions to the CHOOSING phase for the new round.
+
+ /**
+ * Invoked when an individual turn starts (for games with explicit turns).
+ */
+ protected abstract void onTurnStart();
+
+ /**
+ * Invoked after a turn completes for cleanup or state transitions.
+ */
+ protected abstract void onTurnEnd();
+
+ /**
+ * Invoked after a round is finished (cleanup, scoring, etc.).
+ */
+ protected abstract void onRoundEnd();
+ // rk975 - 11/26/25
+ // Advances the round counter, resets per-round state,
+ // and transitions to the CHOOSING phase for the new round.
+
+ /**
+ * Called when the overall session is ending (e.g., game over or aborted).
+ */
+ protected abstract void onSessionEnd();
+ // rk975 - 11/26/25
+ // Stops active timers when the room is empty so things can fully reset
+ // determine and announce winner(s)
+
+ /**
+ * Fires when a client is successfully added to both the base Room map
+ * and this GameRoom's client tracking.
+ *
+ * @param client the newly joined client
+ */
+ protected abstract void onClientAdded(ServerThread client);
+// rk975 - 11/26/25
+// Fires when a client is successfully added to both the base Room map
+// and this GameRoom's client tracking.
+ /**
+ * Fires when a client is removed from both the base Room map
+ * and this GameRoom's client tracking.
+ *
+ * @param client the departing client (may be null if already removed)
+ */
+ protected abstract void onClientRemoved(ServerThread client);
+// rk975 - 11/26/25
+// Fires when a client is removed from both the base Room map
+// and this GameRoom's client tracking.
+
+ @Override
+ protected synchronized void addClient(ServerThread client) {
+ if (!isRunning()) { // ignore join attempts when the Room is inactive
+ return;
+ }
+ // invoke the shared Room-level add logic
+ super.addClient(client);
+ onClientAdded(client);
+ }
+
+ @Override
+ protected synchronized void removeClient(ServerThread client) {
+ if (!isRunning()) { // ignore removals if the Room isn't active
+ return;
+ }
+ LoggerUtil.INSTANCE.info("Players in room: " + clientsInRoom.size());
+ // perform the base Room removal logic
+ super.removeClient(client);
+ onClientRemoved(client);
+ }
+
+ @Override
+ protected synchronized void disconnect(ServerThread client) {
+ super.disconnect(client);
+ LoggerUtil.INSTANCE.info("Players in room: " + clientsInRoom.size());
+ onClientRemoved(client);
+ }
+
+ /**
+ * Stops any active ready timer and clears its reference.
+ */
+ protected void resetReadyTimer() {
+ if (readyTimer != null) {
+ readyTimer.cancel();
+ readyTimer = null;
+ }
+ }
+
+ /**
+ * Starts a new "ready" countdown timer if one isn't already active.
+ *
+ * @param resetOnTry if true, cancels a currently running ready timer first
+ */
+ protected void startReadyTimer(boolean resetOnTry) {
+ if (resetOnTry) {
+ resetReadyTimer();
+ }
+ if (readyTimer == null) {
+ readyTimer = new TimedEvent(30, () -> {
+ // callback executed when the ready timer expires
+ checkReadyStatus();
+ });
+ readyTimer.setTickCallback((time) -> System.out.println("Ready Timer: " + time));
+ }
+ }
+
+ /**
+ * Determines whether enough players are ready to begin the session.
+ * If the requirement is met, the session begins; otherwise, the session ends.
+ */
+ private void checkReadyStatus() {
+ // Always reset the timer after it fires so a new one can be started
+ resetReadyTimer();
+ long numReady = clientsInRoom.values().stream().filter(p -> p.isReady()).count();
+ if (numReady >= MINIMUM_REQUIRED_TO_START) {
+ onSessionStart();
+ } else {
+ onSessionEnd();
+ }
+ }
+
+ /**
+ * Clears all ready flags for players and notifies clients to reset their local state.
+ */
+ protected void resetReadyStatus() {
+ clientsInRoom.values().forEach(p -> p.setReady(false));
+ sendResetReadyTrigger();
+ }
+
+ /**
+ * Attempts to move the game into a new phase if it differs from the current one.
+ * When changed, the updated phase is broadcast to all connected clients.
+ *
+ * @param phase the new phase being requested
+ */
+ protected void changePhase(Phase phase) {
+ if (currentPhase != phase) {
+ currentPhase = phase;
+ sendCurrentPhase();
+ }
+ }
+
+ // send/sync data to ServerThread(s)
+
+ /**
+ * Sends the current phase value down to a single client.
+ *
+ * @param sp the ServerThread to sync with
+ */
+ protected void syncCurrentPhase(ServerThread sp) {
+ sp.sendCurrentPhase(currentPhase);
+ }
+
+ /**
+ * Broadcasts the current phase to all active clients in this room.
+ */
+ protected void sendCurrentPhase() {
+ clientsInRoom.values().removeIf(spInRoom -> {
+ boolean failedToSend = !spInRoom.sendCurrentPhase(currentPhase);
+ if (failedToSend) {
+ removeClient(spInRoom);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Convenience helper to tell all clients to reset their ready status locally.
+ */
+ protected void sendResetReadyTrigger() {
+ clientsInRoom.values().removeIf(spInRoom -> {
+ boolean failedToSend = !spInRoom.sendResetReady();
+ if (failedToSend) {
+ removeClient(spInRoom);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Sends the ready state for each ServerThread in this room to a single client.
+ *
+ * @param incomingSP the client that needs the current ready snapshot
+ */
+ protected void syncReadyStatus(ServerThread incomingSP) {
+ clientsInRoom.values().removeIf(spInRoom -> {
+ boolean failedToSend = !incomingSP.sendReadyStatus(spInRoom.getClientId(), spInRoom.isReady(), true);
+ if (failedToSend) {
+ removeClient(spInRoom);
+ }
+ // only mark for removal if sending failed AND it's the same client
+ return failedToSend && spInRoom.getClientId() == incomingSP.getClientId();
+ });
+ }
+
+ /**
+ * Notifies all clients about a single player's ready status change.
+ *
+ * @param incomingSP the player whose ready status changed
+ * @param isReady the new ready value (kept for clarity, even though we read from incomingSP)
+ */
+ protected void sendReadyStatus(ServerThread incomingSP, boolean isReady) {
+ clientsInRoom.values().removeIf(spInRoom -> {
+ boolean failedToSend = !spInRoom.sendReadyStatus(incomingSP.getClientId(), incomingSP.isReady());
+ if (failedToSend) {
+ removeClient(spInRoom);
+ }
+ return failedToSend;
+ });
+ }
+ // end send data to ServerThread(s)
+
+ // receive data from ServerThread (GameRoom-specific entry points)
+ protected void handleReady(ServerThread sender) {
+ try {
+ // early validation checks
+ checkPlayerInRoom(sender);
+ checkCurrentPhase(sender, Phase.READY);
+
+ ServerThread sp = null;
+ // option 1: simply set ready to true
+ if (!allowToggleReady) {
+ sp = clientsInRoom.get(sender.getClientId());
+ sp.setReady(true);
+ }
+ // option 2: flip the current ready state
+ else {
+ sp = clientsInRoom.get(sender.getClientId());
+ sp.setReady(!sp.isReady());
+ }
+ // kicks off or reuses a timer that will trigger the next stage when it expires
+ startReadyTimer(false);
+
+ sendReadyStatus(sp, sp.isReady());
+ } catch (Exception e) {
+ LoggerUtil.INSTANCE.severe("handleReady exception", e);
+ }
+
+ }
+ // end receive data from ServerThread (GameRoom-specific entry points)
+
+ // Logic Checks
+
+ /**
+ * Guard clause to ensure the requested action matches the current phase.
+ * Throws a PhaseMismatchException and informs the client if there's a mismatch.
+ *
+ * @param client the requesting client
+ * @param check required phase for this action
+ * @throws Exception if the phase is not correct
+ */
+ protected void checkCurrentPhase(ServerThread client, Phase check) throws Exception {
+ if (currentPhase != check) {
+ client.sendMessage(Constants.DEFAULT_CLIENT_ID,
+ String.format("Current phase is %s, please try again later", currentPhase.name()));
+ throw new PhaseMismatchException("Invalid Phase");
+ }
+ }
+
+ /**
+ * Ensures the client is marked as ready before proceeding.
+ *
+ * @param client the client to validate
+ * @throws NotReadyException if the client is not ready
+ */
+ protected void checkIsReady(ServerThread client) throws NotReadyException {
+ if (!client.isReady()) {
+ client.sendMessage(Constants.DEFAULT_CLIENT_ID, "You must be marked 'ready' to do this action");
+ throw new NotReadyException("Not ready");
+ }
+ }
+
+ /**
+ * Validates that the client is actually present in this room.
+ * Throws PlayerNotFoundException if not found.
+ *
+ * @param client the client to look up
+ * @throws Exception if the client is missing from the room
+ */
+ protected void checkPlayerInRoom(ServerThread client) throws Exception {
+ if (!clientsInRoom.containsKey(client.getClientId())) {
+ LoggerUtil.INSTANCE.severe("Player isn't in room");
+ throw new PlayerNotFoundException("Player isn't in room");
+ }
+ }
+ // end Logic Checks
+}
diff --git a/Project/Server/BaseServerThread.java b/Project/Server/BaseServerThread.java
new file mode 100644
index 0000000..bf006f6
--- /dev/null
+++ b/Project/Server/BaseServerThread.java
@@ -0,0 +1,222 @@
+package Project.Server;
+
+import Project.Common.Payload;
+import Project.Common.User;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+
+/**
+ * Abstract base class that manages the low-level connection between a Client
+ * and the server side.
+ */
+public abstract class BaseServerThread extends Thread {
+
+ protected boolean isRunning = false; // control flag used to stop this thread
+ protected ObjectOutputStream out; // exposed here so sendToClient() can use it
+ protected Socket client; // socket tied directly to this specific client
+ protected User user = new User();
+ protected Room currentRoom;
+
+ /**
+ * Returns the Room currently associated with this ServerThread.
+ *
+ * @return the active Room reference
+ */
+ protected Room getCurrentRoom() {
+ return this.currentRoom;
+ }
+
+ /**
+ * Assigns a non-null Room reference to this ServerThread.
+ *
+ * @param room target Room to bind this thread to
+ */
+ protected void setCurrentRoom(Room room) {
+ if (room == null) {
+ throw new NullPointerException("Room argument can't be null");
+ }
+ if (room == currentRoom) {
+ System.out.println(
+ String.format("ServerThread set to the same room [%s], was this intentional?", room.getName()));
+ }
+ currentRoom = room;
+ }
+
+ /**
+ * Indicates whether this ServerThread is currently active.
+ *
+ * @return true if the thread is running, false otherwise
+ */
+ public boolean isRunning() {
+ return isRunning;
+ }
+
+ public void setClientId(long clientId) {
+ this.user.setClientId(clientId);
+ }
+
+ public long getClientId() {
+ // Note: We return clientId instead of the thread id since this identifier
+ // may later change independently of the thread.
+ return this.user.getClientId();
+ }
+
+ /**
+ * Sets the client's name and then invokes onInitialized() once
+ * basic identity information is available.
+ *
+ * @param clientName the name to associate with this client
+ */
+ protected void setClientName(String clientName) {
+ this.user.setClientName(clientName);
+ onInitialized();
+ }
+
+ public String getClientName() {
+ return this.user.getClientName();
+ }
+
+ public String getDisplayName() {
+ return this.user.getDisplayName();
+ }
+
+ /**
+ * Convenience method to abstract away the logging/printing implementation.
+ *
+ * @param message text to log/display
+ */
+ protected abstract void info(String message);
+
+ /**
+ * Called when this object has finished its initialization sequence.
+ */
+ protected abstract void onInitialized();
+
+ /**
+ * Receives an incoming Payload and routes it to the correct handler method.
+ *
+ * @param payload data object sent from the client
+ */
+ protected abstract void processPayload(Payload payload);
+
+ /**
+ * Serializes and sends a Payload over the socket to the client.
+ *
+ * @param payload object to send
+ * @return true if the send operation completed successfully
+ */
+ protected boolean sendToClient(Payload payload) {
+ if (!isRunning) {
+ return true;
+ }
+ try {
+ info("Sending to client: " + payload);
+ out.writeObject(payload);
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ info("Error sending message to client (most likely disconnected)");
+ // uncomment to inspect full stack trace
+ // e.printStackTrace();
+ cleanup();
+ return false;
+ }
+ }
+
+ /**
+ * Shuts down the server side of this connection.
+ * Safe to call multiple times; subsequent calls are ignored.
+ */
+ protected void disconnect() {
+ if (!isRunning) {
+ // avoid executing disconnect logic more than once
+ return;
+ }
+ info("Thread being disconnected by server");
+ isRunning = false;
+ this.interrupt(); // breaks out of the blocking read loop in run()
+ cleanup(); // finalize connections and release resources
+ }
+
+ @Override
+ public void run() {
+ info("Thread starting");
+ try (ObjectOutputStream out = new ObjectOutputStream(client.getOutputStream());
+ ObjectInputStream in = new ObjectInputStream(client.getInputStream());) {
+ this.out = out;
+ isRunning = true;
+ new java.util.Timer().schedule(new java.util.TimerTask() {
+ @Override
+ public void run() {
+ if (getClientName() == null || getClientName().isBlank()) {
+ info("Client name not received. Disconnecting");
+ disconnect();
+ }
+ }
+ }, 3000);
+ Payload fromClient;
+ /**
+ * isRunning acts as a flag controlling when to exit the main loop.
+ * fromClient (in.readObject()) is a blocking call that waits for incoming data.
+ * - null generally indicates some sort of disconnect, so we use "set then check"
+ * logic to decide when to break out of the loop.
+ */
+ while (isRunning) {
+ try {
+ fromClient = (Payload) in.readObject(); // this call blocks until data arrives
+ if (fromClient != null) {
+ info("Received from my client: " + fromClient);
+ processPayload(fromClient);
+ } else {
+ throw new IOException("Connection interrupted"); // explicit exception for a clean exit path
+ }
+ } catch (ClassCastException | ClassNotFoundException cce) {
+ System.err.println("Error reading object as specified type: " + cce.getMessage());
+ cce.printStackTrace();
+ } catch (IOException e) {
+ if (Thread.currentThread().isInterrupted()) {
+ info("Thread interrupted during read (likely from the disconnect() method)");
+ break;
+ }
+ info("IO exception while reading from client");
+ e.printStackTrace();
+ break;
+ }
+ } // close while loop
+ } catch (Exception e) {
+ // typically occurs when the client disconnects unexpectedly
+ info("General Exception");
+ e.printStackTrace();
+ info("My Client disconnected");
+ } finally {
+ if (currentRoom != null) {
+ currentRoom.handleDisconnect(this);
+ }
+ isRunning = false;
+ info("Exited thread loop. Cleaning up connection");
+ cleanup();
+ }
+ }
+
+ /**
+ * Cleans up this ServerThread's resources by closing the socket, clearing
+ * references, and resetting the user state.
+ */
+ protected void cleanup() {
+ info("ServerThread cleanup() start");
+ try {
+ // close the server-side end of the socket connection
+ currentRoom = null;
+ out.close();
+ client.close();
+ user.reset();
+ info("Closed Server-side Socket");
+ } catch (IOException e) {
+ info("Client already closed");
+ }
+
+ info("ServerThread cleanup() end");
+ }
+}
diff --git a/Project/Server/GameRoom.java b/Project/Server/GameRoom.java
new file mode 100644
index 0000000..1a4f9c5
--- /dev/null
+++ b/Project/Server/GameRoom.java
@@ -0,0 +1,508 @@
+package Project.Server;
+
+import Project.Common.Constants;
+import Project.Common.LoggerUtil;
+import Project.Common.Phase;
+import Project.Common.TimedEvent;
+import Project.Exceptions.NotReadyException;
+import Project.Exceptions.PhaseMismatchException;
+import Project.Exceptions.PlayerNotFoundException;
+
+public class GameRoom extends BaseGameRoom {
+
+ // Timer that governs full rounds (often tied to phase-based actions)
+ private TimedEvent roundTimer = null;
+
+ // Timer for per-player turns (typically used when enforcing turn order)
+ private TimedEvent turnTimer = null;
+
+ private int round = 0;
+
+ // If true, a single defeat (on attack or defense) removes a player from the game.
+ // If false, a player must lose both encounters in the round before they are out.
+ private final boolean ELIMINATE_ON_SINGLE_LOSS = true;
+
+ public GameRoom(String name) {
+ super(name);
+ }
+
+ /** {@inheritDoc} */
+ // rk975 - 11/26/25
+ // Fires when a client is successfully added to both the base Room map
+ // and this GameRoom's client tracking.
+ // Aligns the newly-joined client with the current GameRoom state
+ @Override
+ protected void onClientAdded(ServerThread sp) {
+ // align the newly-joined client with current GameRoom state
+ syncCurrentPhase(sp);
+ syncReadyStatus(sp);
+ syncTurnStatus(sp);
+ }
+
+ /** {@inheritDoc} */
+ // rk975 - 11/26/25
+ // Fires when a client is removed from both the base Room map
+ // and this GameRoom's client tracking.
+ // Stops active timers when the room is empty so things can fully reset
+ @Override
+ protected void onClientRemoved(ServerThread sp) {
+ // added after Summer 2024 Demo
+ // Stop active timers when the room is empty so things can fully reset
+ LoggerUtil.INSTANCE.info("Player Removed, remaining: " + clientsInRoom.size());
+
+ if (clientsInRoom.isEmpty()) {
+ resetReadyTimer();
+ resetTurnTimer();
+ resetRoundTimer();
+ onSessionEnd();
+ }
+ }
+
+ // timer handlers
+ private void startRoundTimer() {
+ roundTimer = new TimedEvent(30, () -> onRoundEnd());
+ roundTimer.setTickCallback((time) -> System.out.println("Round Time: " + time));
+ }
+
+ private void resetRoundTimer() {
+ if (roundTimer != null) {
+ roundTimer.cancel();
+ roundTimer = null;
+ }
+ }
+
+ private void startTurnTimer() {
+ turnTimer = new TimedEvent(30, () -> onTurnEnd());
+ turnTimer.setTickCallback((time) -> System.out.println("Turn Time: " + time));
+ }
+
+ private void resetTurnTimer() {
+ if (turnTimer != null) {
+ turnTimer.cancel();
+ turnTimer = null;
+ }
+ }
+ // end timer handlers
+
+ // lifecycle methods
+
+ /** {@inheritDoc} */
+ // rk975 - 11/26/25
+ // Initializes game session state, resets round counter,
+ // and transitions to the CHOOSING phase to start the first round.
+ @Override
+ protected void onSessionStart() {
+ LoggerUtil.INSTANCE.info("onSessionStart() start");
+ round = 0;
+ // Kick off the session in the CHOOSING phase for the RPS rounds
+ changePhase(Phase.CHOOSING);
+ LoggerUtil.INSTANCE.info("onSessionStart() end");
+ onRoundStart();
+ }
+
+ /** {@inheritDoc} */
+ // rk975 - 11/26/25
+ // Advances the round counter, resets per-round state,
+ // and transitions to the CHOOSING phase for the new round.
+ // Starts the round timer to limit the duration of the round.
+ @Override
+ protected void onRoundStart() {
+ LoggerUtil.INSTANCE.info("onRoundStart() start");
+ resetRoundTimer();
+ resetTurnStatus();
+ round++;
+ // move into the choosing phase and prep player choices for active participants
+ changePhase(Phase.CHOOSING);
+ clientsInRoom.values().forEach(sp -> {
+ // clear choice only for players still in the game
+ sp.user.setChoice(null);
+ });
+ relay(null, "Round " + round + " started. Pick with /pick ");
+ startRoundTimer();
+ LoggerUtil.INSTANCE.info("onRoundStart() end");
+ // Note: per-turn lifecycle hooks are not used here
+ // Players perform their actions within the window between roundStart and roundEnd
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void onTurnStart() {
+ LoggerUtil.INSTANCE.info("onTurnStart() start");
+ resetTurnTimer();
+
+ startTurnTimer();
+ LoggerUtil.INSTANCE.info("onTurnStart() end");
+ }
+
+ // Note: The actual gameplay logic between Turn Start and Turn End
+ // is usually driven by incoming player actions and the timers above.
+ /** {@inheritDoc} */
+ @Override
+ protected void onTurnEnd() {
+ LoggerUtil.INSTANCE.info("onTurnEnd() start");
+ // clear the turn timer if the turn concluded before timeout
+ resetTurnTimer();
+ LoggerUtil.INSTANCE.info("onTurnEnd() end");
+ }
+
+ // Note: Round-to-round logic is similarly handled via timers and player input.
+ /** {@inheritDoc} */
+ // rk975 - 11/26/25
+ // Advances the round counter, resets per-round state,
+ // and transitions to the CHOOSING phase for the new round.
+ // Starts the round timer to limit the duration of the round.
+ @Override
+ protected void onRoundEnd() {
+ LoggerUtil.INSTANCE.info("onRoundEnd() start");
+ // reset the round timer if the round ended normally
+ resetRoundTimer();
+ LoggerUtil.INSTANCE.info("onRoundEnd() end");
+ relay(null, "Round ended — processing results...");
+ // Handle end-of-round logic: auto-eliminate non-pickers, resolve battles, assign points
+ processRoundResults();
+ // Evaluate whether the game should end or continue
+ long active = clientsInRoom.values().stream().filter(sp -> !sp.user.isEliminated()).count();
+ if (active <= 1) {
+ onSessionEnd();
+ } else {
+ // proceed to the next round
+ onRoundStart();
+ }
+ }
+
+ /** {@inheritDoc} */
+ // rk975 - 11/26/25
+ // Stops active timers when the room is empty so things can fully reset
+ // determine and announce winner(s)
+ // push out final scores sorted by total points
+ // clear per-session player state in preparation for a fresh game (clients
+ @Override
+ protected void onSessionEnd() {
+ LoggerUtil.INSTANCE.info("onSessionEnd() start");
+ resetReadyStatus();
+ resetTurnStatus();
+ // determine and announce winner(s)
+ java.util.List alive = clientsInRoom.values().stream()
+ .filter(sp -> !sp.user.isEliminated()).toList();
+ if (alive.size() == 1) {
+ relay(null, String.format("%s is the winner!", alive.get(0).getDisplayName()));
+ } else if (alive.size() == 0) {
+ relay(null, "No players remaining — tie");
+ }
+
+ // push out final scores sorted by total points
+ sendFinalScoreboard();
+
+ // clear per-session player state in preparation for a fresh game (clients remain connected)
+ clientsInRoom.values().forEach(sp -> {
+ sp.user.setPoints(0);
+ sp.user.setEliminated(false);
+ sp.user.setChoice(null);
+ sp.setTookTurn(false);
+ sp.setReady(false);
+ });
+
+ // sync point resets to all clients
+ clientsInRoom.values().forEach(sp -> {
+ clientsInRoom.values().forEach(target -> target.sendPointsUpdate(sp.getClientId(), sp.user.getPoints()));
+ });
+
+ changePhase(Phase.READY);
+ LoggerUtil.INSTANCE.info("onSessionEnd() end");
+ }
+ // end lifecycle methods
+
+ // send/sync data to ServerThread(s)
+ private void sendResetTurnStatus() {
+ clientsInRoom.values().forEach(spInRoom -> {
+ boolean failedToSend = !spInRoom.sendResetTurnStatus();
+ if (failedToSend) {
+ removeClient(spInRoom);
+ }
+ });
+ }
+
+ private void sendTurnStatus(ServerThread client, boolean tookTurn) {
+ clientsInRoom.values().removeIf(spInRoom -> {
+ boolean failedToSend = !spInRoom.sendTurnStatus(client.getClientId(), client.didTakeTurn());
+ if (failedToSend) {
+ removeClient(spInRoom);
+ }
+ return failedToSend;
+ });
+ }
+
+ private void syncTurnStatus(ServerThread incomingClient) {
+ clientsInRoom.values().forEach(serverUser -> {
+ if (serverUser.getClientId() != incomingClient.getClientId()) {
+ boolean failedToSync = !incomingClient.sendTurnStatus(serverUser.getClientId(),
+ serverUser.didTakeTurn(), true);
+ if (failedToSync) {
+ LoggerUtil.INSTANCE.warning(
+ String.format("Removing disconnected %s from list", serverUser.getDisplayName()));
+ disconnect(serverUser);
+ }
+ }
+ });
+ }
+
+ // end send data to ServerThread(s)
+
+ // misc methods
+ private void resetTurnStatus() {
+ clientsInRoom.values().forEach(sp -> {
+ sp.setTookTurn(false);
+ });
+ sendResetTurnStatus();
+ }
+
+ private void checkAllTookTurn() {
+ int numReady = clientsInRoom.values().stream()
+ .filter(sp -> sp.isReady())
+ .toList().size();
+ int numTookTurn = clientsInRoom.values().stream()
+ // Must still be marked ready, based on the original group
+ .filter(sp -> sp.isReady() && sp.didTakeTurn())
+ .toList().size();
+ if (numReady == numTookTurn) {
+ relay(null,
+ String.format("All players have taken their turn (%d/%d) ending the round", numTookTurn, numReady));
+ onRoundEnd();
+ }
+ }
+
+ // start check methods
+
+ // end check methods
+
+ // receive data from ServerThread (GameRoom specific)
+
+ /**
+ * Processes a turn request from a client.
+ *
+ * @param currentUser the ServerThread for the acting client
+ * @param exampleText an arbitrary string from the client that could be used
+ * for additional per-turn data
+ */
+ protected void handleTurnAction(ServerThread currentUser, String exampleText) {
+ // validate that the caller is in the room and that conditions are correct
+ try {
+ checkPlayerInRoom(currentUser);
+ checkCurrentPhase(currentUser, Phase.IN_PROGRESS);
+ checkIsReady(currentUser);
+ if (currentUser.didTakeTurn()) {
+ currentUser.sendMessage(Constants.DEFAULT_CLIENT_ID, "You have already taken your turn this round");
+ return;
+ }
+ currentUser.setTookTurn(true);
+ sendTurnStatus(currentUser, currentUser.didTakeTurn());
+ // TODO: incorporate exampleText for richer turn handling logic if desired
+ // completion of the current user's turn
+ checkAllTookTurn();
+ } catch (NotReadyException e) {
+ // NotReady check already sends a message to currentUser
+ LoggerUtil.INSTANCE.severe("handleTurnAction exception", e);
+ } catch (PlayerNotFoundException e) {
+ currentUser.sendMessage(Constants.DEFAULT_CLIENT_ID, "You must be in a GameRoom to do the ready check");
+ LoggerUtil.INSTANCE.severe("handleTurnAction exception", e);
+ } catch (PhaseMismatchException e) {
+ currentUser.sendMessage(Constants.DEFAULT_CLIENT_ID,
+ "You can only take a turn during the IN_PROGRESS phase");
+ LoggerUtil.INSTANCE.severe("handleTurnAction exception", e);
+ } catch (Exception e) {
+ LoggerUtil.INSTANCE.severe("handleTurnAction exception", e);
+ }
+ }
+
+ // end receive data from ServerThread (GameRoom specific)
+
+ /**
+ * Overrides the default message handler so that plain single-letter
+ * choices (r/p/s) during CHOOSING are interpreted as game picks instead
+ * of being broadcast as chat text.
+ */
+ @Override
+ protected synchronized void handleMessage(ServerThread sender, String text) {
+ try {
+ if (currentPhase == Phase.CHOOSING && text != null) {
+ String t = text.trim();
+ String tl = t.toLowerCase();
+ // direct one-character selection like "r"
+ if (tl.length() == 1 && (tl.equals("r") || tl.equals("p") || tl.equals("s"))) {
+ handlePick(sender, tl);
+ return;
+ }
+ // Some clients may not interpret slash commands correctly and send them as messages.
+ // Accept patterns like "/pick p", "pick p", etc.
+ String withoutSlash = tl.startsWith("/") ? tl.substring(1).trim() : tl;
+ if (withoutSlash.startsWith("pick")) {
+ String[] parts = withoutSlash.split(" +");
+ if (parts.length >= 2) {
+ String choice = parts[1].trim();
+ if (choice.length() == 1 && (choice.equals("r") || choice.equals("p") || choice.equals("s"))) {
+ handlePick(sender, choice);
+ return;
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ LoggerUtil.INSTANCE.severe("handleMessage override error", e);
+ }
+ // fall back to the parent implementation for non-pick cases
+ super.handleMessage(sender, text);
+ }
+
+ /**
+ * Handles a player's rock/paper/scissors choice.
+ *
+ * @param currentUser the ServerThread for the client choosing
+ * @param choiceStr expecting "r", "p", or "s"
+ */
+ // rk975 - 11/26/25
+ // Handles a player's pick choice during the game.
+ // Expects a PickPayload containing the choice ("r", "p", or "s").
+ // Delegates the processing to the GameRoom's handlePick() method.
+ protected void handlePick(ServerThread currentUser, String choiceStr) {
+ try {
+ checkPlayerInRoom(currentUser);
+ checkCurrentPhase(currentUser, Phase.CHOOSING);
+ checkIsReady(currentUser);
+ if (currentUser.user.isEliminated()) {
+ currentUser.sendMessage(Constants.DEFAULT_CLIENT_ID, "You are eliminated and cannot pick");
+ return;
+ }
+ if (choiceStr == null || choiceStr.isBlank()) {
+ currentUser.sendMessage(Constants.DEFAULT_CLIENT_ID, "Invalid choice");
+ return;
+ }
+ String c = choiceStr.trim().toLowerCase();
+ if (!(c.equals("r") || c.equals("p") || c.equals("s"))) {
+ currentUser.sendMessage(Constants.DEFAULT_CLIENT_ID, "Choice must be r, p, or s");
+ return;
+ }
+ currentUser.user.setChoice(c);
+ relay(currentUser, String.format("%s picked their choice", currentUser.getDisplayName()));
+ // check whether all still-active players have submitted a choice
+ boolean allChosen = clientsInRoom.values().stream()
+ .filter(sp -> !sp.user.isEliminated())
+ .allMatch(sp -> sp.user.getChoice() != null);
+ if (allChosen) {
+ onRoundEnd();
+ }
+ } catch (Exception e) {
+ LoggerUtil.INSTANCE.severe("handlePick exception", e);
+ }
+ }
+
+ private boolean beats(String a, String b) {
+ // returns true if the first choice wins against the second in RPS
+ if (a == null || b == null)
+ return false;
+ if (a.equals("r") && b.equals("s"))
+ return true;
+ if (a.equals("s") && b.equals("p"))
+ return true;
+ if (a.equals("p") && b.equals("r"))
+ return true;
+ return false;
+ }
+
+ private void processRoundResults() {
+ // first remove any active players who never submitted a choice
+ clientsInRoom.values().forEach(sp -> {
+ if (!sp.user.isEliminated() && sp.user.getChoice() == null) {
+ sp.user.setEliminated(true);
+ relay(null, String.format("%s did not pick and is eliminated", sp.getDisplayName()));
+ }
+ });
+
+ // assemble a list of still-active (non-eliminated) participants
+ var active = clientsInRoom.values().stream().filter(sp -> !sp.user.isEliminated()).toList();
+ int n = active.size();
+ if (n <= 1) {
+ // with 0 or 1 player remaining, there is nothing to resolve
+ return;
+ }
+
+ // iterate over players in a round-robin style and track both points and losses
+ java.util.Map addPoints = new java.util.HashMap<>();
+ java.util.Map lossCount = new java.util.HashMap<>();
+
+ for (int i = 0; i < n; i++) {
+ ServerThread attacker = active.get(i);
+ ServerThread defender = active.get((i + 1) % n);
+ String aChoice = attacker.user.getChoice();
+ String dChoice = defender.user.getChoice();
+ if (aChoice == null || dChoice == null) {
+ continue; // skip incomplete pairs
+ }
+
+ String resultMsg;
+ if (beats(aChoice, dChoice)) {
+ addPoints.put(attacker, addPoints.getOrDefault(attacker, 0) + 1);
+ lossCount.put(defender, lossCount.getOrDefault(defender, 0) + 1);
+ resultMsg = String.format("Battle: %s (%s) vs %s (%s) -> %s wins", attacker.getDisplayName(), aChoice,
+ defender.getDisplayName(), dChoice, attacker.getDisplayName());
+ } else if (beats(dChoice, aChoice)) {
+ addPoints.put(defender, addPoints.getOrDefault(defender, 0) + 1);
+ lossCount.put(attacker, lossCount.getOrDefault(attacker, 0) + 1);
+ resultMsg = String.format("Battle: %s (%s) vs %s (%s) -> %s wins", attacker.getDisplayName(), aChoice,
+ defender.getDisplayName(), dChoice, defender.getDisplayName());
+ } else {
+ resultMsg = String.format("Battle: %s (%s) vs %s (%s) -> tie", attacker.getDisplayName(), aChoice,
+ defender.getDisplayName(), dChoice);
+ }
+ // announce the matchup and outcome after the round finishes
+ relay(null, resultMsg);
+ }
+
+ // award accumulated points and push updates to all clients
+ addPoints.forEach((sp, pts) -> {
+ sp.user.setPoints(sp.user.getPoints() + pts);
+ // broadcast updated scores
+ clientsInRoom.values().forEach(sendTo -> {
+ boolean ok = sendTo.sendPointsUpdate(sp.getClientId(), sp.user.getPoints());
+ if (!ok) {
+ removeClient(sendTo);
+ }
+ });
+ });
+
+ // figure out who should be eliminated based on how many losses they took
+ java.util.Set toEliminate = new java.util.HashSet<>();
+ for (ServerThread sp : active) {
+ int losses = lossCount.getOrDefault(sp, 0);
+ boolean eliminate = ELIMINATE_ON_SINGLE_LOSS ? losses >= 1 : losses >= 2;
+ if (eliminate) {
+ toEliminate.add(sp);
+ }
+ }
+
+ // mark players as eliminated and let everyone know
+ toEliminate.forEach(sp -> {
+ sp.user.setEliminated(true);
+ relay(null, String.format("%s has been eliminated", sp.getDisplayName()));
+ });
+
+ }
+
+ /**
+ * Assembles and broadcasts a final scoreboard, ordered by total points.
+ */
+ private void sendFinalScoreboard() {
+ java.util.List sorted = clientsInRoom.values().stream()
+ .sorted((a, b) -> Integer.compare(b.user.getPoints(), a.user.getPoints())).toList();
+ StringBuilder sb = new StringBuilder();
+ sb.append("Final Scoreboard:\n");
+ sorted.forEach(sp -> sb.append(String.format("%s : %d\n", sp.getDisplayName(), sp.user.getPoints())));
+ relay(null, sb.toString());
+ }
+
+ /**
+ * Handles a live scoreboard request from a client by sending the current standings.
+ */
+ protected void handleScoreboard(ServerThread requester) {
+ sendFinalScoreboard();
+ }
+}
diff --git a/Project/Server/Room.java b/Project/Server/Room.java
new file mode 100644
index 0000000..c5f64b8
--- /dev/null
+++ b/Project/Server/Room.java
@@ -0,0 +1,277 @@
+package Project.Server;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import Project.Common.Constants;
+import Project.Common.LoggerUtil;
+import Project.Common.RoomAction;
+import Project.Common.TextFX;
+import Project.Common.TextFX.Color;
+import Project.Exceptions.DuplicateRoomException;
+import Project.Exceptions.RoomNotFoundException;
+
+public class Room implements AutoCloseable {
+ private final String name;// unique name of the Room
+ private volatile boolean isRunning = false;
+ protected final ConcurrentHashMap clientsInRoom = new ConcurrentHashMap();
+
+ public final static String LOBBY = "lobby";
+
+ private void info(String message) {
+ LoggerUtil.INSTANCE.info(TextFX.colorize(String.format("Room[%s]: %s", name, message), Color.PURPLE));
+ }
+
+ public Room(String name) {
+ this.name = name;
+ isRunning = true;
+ info("Created");
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ protected boolean isRunning() {
+ return isRunning;
+ }
+
+ protected synchronized void addClient(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ if (clientsInRoom.containsKey(client.getClientId())) {
+ info("Attempting to add a client that already exists in the room");
+ return;
+ }
+ clientsInRoom.put(client.getClientId(), client);
+ client.setCurrentRoom(this);
+ client.sendResetUserList();
+ syncExistingClients(client);
+ // notify clients of someone joining
+ joinStatusRelay(client, true);
+
+ }
+
+ protected synchronized void removeClient(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ if (!clientsInRoom.containsKey(client.getClientId())) {
+ info("Attempting to remove a client that doesn't exist in the room");
+ return;
+ }
+ ServerThread removedClient = clientsInRoom.get(client.getClientId());
+ if (removedClient != null) {
+ // notify clients of someone joining
+ joinStatusRelay(removedClient, false);
+ clientsInRoom.remove(client.getClientId());
+ autoCleanup();
+ }
+ }
+
+ private void syncExistingClients(ServerThread incomingClient) {
+ clientsInRoom.values().forEach(serverThread -> {
+ if (serverThread.getClientId() != incomingClient.getClientId()) {
+ boolean failedToSync = !incomingClient.sendClientInfo(serverThread.getClientId(),
+ serverThread.getClientName(), RoomAction.JOIN, true);
+ if (failedToSync) {
+ LoggerUtil.INSTANCE.warning(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ }
+ });
+ }
+
+ private void joinStatusRelay(ServerThread client, boolean didJoin) {
+ clientsInRoom.values().removeIf(serverThread -> {
+ String formattedMessage = String.format("Room[%s] %s %s the room",
+ getName(),
+ client.getClientId() == serverThread.getClientId() ? "You"
+ : client.getDisplayName(),
+ didJoin ? "joined" : "left");
+ final long senderId = client == null ? Constants.DEFAULT_CLIENT_ID : client.getClientId();
+ // Share info of the client joining or leaving the room
+ boolean failedToSync = !serverThread.sendClientInfo(client.getClientId(),
+ client.getClientName(), didJoin ? RoomAction.JOIN : RoomAction.LEAVE);
+ // Send the server generated message to the current client
+ boolean failedToSend = !serverThread.sendMessage(senderId, formattedMessage);
+ if (failedToSend || failedToSync) {
+ LoggerUtil.INSTANCE.warning(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Sends a basic String message from the sender to all connectedClients
+ * Internally calls processCommand and evaluates as necessary.
+ * Note: Clients that fail to receive a message get removed from
+ * connectedClients.
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param message
+ * @param sender ServerThread (client) sending the message or null if it's a
+ * server-generated message
+ */
+ protected synchronized void relay(ServerThread sender, String message) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+
+ // Note: any desired changes to the message must be done before this line
+ final String senderString = sender == null ? String.format("Room[%s]", getName())
+ : sender.getDisplayName();
+ final long senderId = sender == null ? Constants.DEFAULT_CLIENT_ID : sender.getClientId();
+ // Note: formattedMessage must be final (or effectively final) since outside
+ // scope can't be changed inside a callback function (see removeIf() below)
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+
+ // loop over clients and send out the message; remove client if message failed
+ // to be sent
+ // Note: this uses a lambda expression for each item in the values() collection,
+ // it's one way we can safely remove items during iteration
+ info(String.format("sending message to %s recipients: %s", clientsInRoom.size(), formattedMessage));
+
+ clientsInRoom.values().removeIf(serverThread -> {
+ boolean failedToSend = !serverThread.sendMessage(senderId, formattedMessage);
+ if (failedToSend) {
+ LoggerUtil.INSTANCE.warning(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ }
+
+ /**
+ * Takes a ServerThread and removes them from the Server
+ * Adding the synchronized keyword ensures that only one thread can execute
+ * these methods at a time,
+ * preventing concurrent modification issues and ensuring thread safety
+ *
+ * @param client
+ */
+ protected synchronized void disconnect(ServerThread client) {
+ if (!isRunning) { // block action if Room isn't running
+ return;
+ }
+ ServerThread disconnectingServerThread = clientsInRoom.remove(client.getClientId());
+ if (disconnectingServerThread != null) {
+
+ clientsInRoom.values().removeIf(serverThread -> {
+ if (serverThread.getClientId() == disconnectingServerThread.getClientId()) {
+ return true;
+ }
+ boolean failedToSend = !serverThread.sendClientInfo(disconnectingServerThread.getClientId(),
+ disconnectingServerThread.getClientName(), RoomAction.LEAVE);
+ if (failedToSend) {
+ LoggerUtil.INSTANCE.warning(
+ String.format("Removing disconnected %s from list", serverThread.getDisplayName()));
+ disconnect(serverThread);
+ }
+ return failedToSend;
+ });
+ relay(null, disconnectingServerThread.getDisplayName() + " disconnected");
+ disconnectingServerThread.disconnect();
+ }
+ autoCleanup();
+ }
+
+ protected synchronized void disconnectAll() {
+ info("Disconnect All triggered");
+ if (!isRunning) {
+ return;
+ }
+ clientsInRoom.values().removeIf(client -> {
+ disconnect(client);
+ return true;
+ });
+ info("Disconnect All finished");
+ }
+
+ /**
+ * Attempts to close the room to free up resources if it's empty
+ */
+ private void autoCleanup() {
+ if (!Room.LOBBY.equalsIgnoreCase(name) && clientsInRoom.isEmpty()) {
+ close();
+ }
+ }
+
+ @Override
+ public void close() {
+ // attempt to gracefully close and migrate clients
+ if (!clientsInRoom.isEmpty()) {
+ relay(null, "Room is shutting down, migrating to lobby");
+ info(String.format("migrating %s clients", clientsInRoom.size()));
+ clientsInRoom.values().removeIf(client -> {
+ try {
+ Server.INSTANCE.joinRoom(Room.LOBBY, client);
+ } catch (RoomNotFoundException e) {
+ e.printStackTrace();
+ // TODO, fill in, this shouldn't happen though
+ }
+ return true;
+ });
+ }
+ Server.INSTANCE.removeRoom(this);
+ isRunning = false;
+ clientsInRoom.clear();
+ info(String.format("closed"));
+ }
+
+ // start handle methods
+ protected void handleListRooms(ServerThread sender, String roomQuery) {
+ sender.sendRooms(Server.INSTANCE.listRooms(roomQuery));
+ }
+
+ public void handleCreateRoom(ServerThread sender, String roomName) {
+ try {
+ Server.INSTANCE.createRoom(roomName);
+ Server.INSTANCE.joinRoom(roomName, sender);
+ } catch (RoomNotFoundException e) {
+ info("Room wasn't found (this shouldn't happen)");
+ e.printStackTrace();
+ } catch (DuplicateRoomException e) {
+ sender.sendMessage(Constants.DEFAULT_CLIENT_ID, String.format("Room %s already exists", roomName));
+ }
+ }
+
+ public void handleJoinRoom(ServerThread sender, String roomName) {
+ try {
+ Server.INSTANCE.joinRoom(roomName, sender);
+ } catch (RoomNotFoundException e) {
+ sender.sendMessage(Constants.DEFAULT_CLIENT_ID, String.format("Room %s doesn't exist", roomName));
+ }
+ }
+
+ protected synchronized void handleDisconnect(BaseServerThread sender) {
+ handleDisconnect((ServerThread) sender);
+ }
+
+ /**
+ * Expose access to the disconnect action
+ *
+ * @param serverThread
+ */
+ protected synchronized void handleDisconnect(ServerThread sender) {
+ disconnect(sender);
+ }
+
+ protected synchronized void handleReverseText(ServerThread sender, String text) {
+ StringBuilder sb = new StringBuilder(text);
+ sb.reverse();
+ String rev = sb.toString();
+ relay(sender, rev);
+ }
+
+ protected synchronized void handleMessage(ServerThread sender, String text) {
+ relay(sender, text);
+ }
+ // end handle methods
+}
\ No newline at end of file
diff --git a/Project/Server/Server.java b/Project/Server/Server.java
new file mode 100644
index 0000000..72d9b48
--- /dev/null
+++ b/Project/Server/Server.java
@@ -0,0 +1,227 @@
+package Project.Server;
+
+import Project.Common.LoggerUtil;
+import Project.Common.TextFX;
+import Project.Common.TextFX.Color;
+import Project.Exceptions.DuplicateRoomException;
+import Project.Exceptions.RoomNotFoundException;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+public enum Server {
+ INSTANCE; // Singleton instance representing the one and only Server
+
+ {
+ // initialize the server-side LoggerUtil configuration once
+ LoggerUtil.LoggerConfig config = new LoggerUtil.LoggerConfig();
+ config.setFileSizeLimit(2048 * 1024); // cap each log file at 2MB
+ config.setFileCount(1); // keep a single rotating log file
+ config.setLogLocation("server.log"); // write server logs to this file
+ // apply the logger configuration
+ LoggerUtil.INSTANCE.setConfig(config);
+ }
+
+ private int port = 3000;
+ // actively tracked rooms
+ // ConcurrentHashMap ensures thread-safe modifications when rooms are added/removed
+ // The map key is the lowercase room name, the value is the associated Room instance
+ private final ConcurrentHashMap rooms = new ConcurrentHashMap<>();
+ private boolean isRunning = true;
+ private long nextClientId = 0;
+
+ private void info(String message) {
+ LoggerUtil.INSTANCE.info(TextFX.colorize(String.format("Server: %s", message), Color.YELLOW));
+ }
+
+ private Server() {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ info("JVM is shutting down. Perform cleanup tasks.");
+ shutdown();
+ }));
+ }
+
+ /**
+ * Attempts to gracefully disconnect clients and clean up Rooms.
+ */
+ private void shutdown() {
+ try {
+ // use removeIf instead of forEach to avoid potential ConcurrentModificationException,
+ // since empty rooms will request removal from this map
+ rooms.values().removeIf(room -> {
+ room.disconnectAll();
+ return true;
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void start(int port) {
+ this.port = port;
+ // begin listening for incoming client connections
+ info("Listening on port " + this.port);
+ // simplified loop for accepting and wiring new client connections
+ try (ServerSocket serverSocket = new ServerSocket(port)) {
+ createRoom(Room.LOBBY); // ensure the lobby exists as the initial/default room
+ while (isRunning) {
+ info("Waiting for next client");
+ Socket incomingClient = serverSocket.accept(); // blocking call until a client connects
+ info("Client connected");
+ // wrap each client socket in a ServerThread; provide callback to notify when ready
+ ServerThread serverThread = new ServerThread(incomingClient, this::onServerThreadInitialized);
+ // start thread execution (lifecycle typically managed by the server, not the thread itself)
+ serverThread.start();
+ // Note: we don't yet insert the ServerThread into any room-specific collection here
+ }
+ } catch (DuplicateRoomException e) {
+ LoggerUtil.INSTANCE.severe(TextFX.colorize("Lobby already exists (this shouldn't happen)", Color.RED));
+ } catch (IOException e) {
+ LoggerUtil.INSTANCE.severe(TextFX.colorize("Error accepting connection", Color.RED));
+ e.printStackTrace();
+ } finally {
+ info("Closing server socket");
+ }
+ }
+
+ /**
+ * Callback invoked by ServerThread when it has finished its basic initialization
+ * and is ready for use by the Server.
+ *
+ * @param serverThread the newly initialized ServerThread instance
+ */
+ private void onServerThreadInitialized(ServerThread serverThread) {
+ // Generate a server-controlled unique clientId
+ nextClientId = Math.max(++nextClientId, 1);
+ serverThread.setClientId(nextClientId);
+ serverThread.sendClientId(); // synchronize the identifier back to the client
+ // place newly initialized client into the lobby
+ info(String.format("*%s initialized*", serverThread.getDisplayName()));
+ try {
+ joinRoom(Room.LOBBY, serverThread);
+ info(String.format("*%s added to Lobby*", serverThread.getDisplayName()));
+ } catch (RoomNotFoundException e) {
+ info(String.format("*Error adding %s to Lobby*", serverThread.getDisplayName()));
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Attempts to create and register a new Room with the server.
+ *
+ * @param name Unique name identifying the room
+ * @return true if room creation succeeds
+ * @throws DuplicateRoomException if a room with the same name already exists
+ */
+ protected void createRoom(String name) throws DuplicateRoomException {
+ final String nameCheck = name.toLowerCase();
+ if (rooms.containsKey(nameCheck)) {
+ throw new DuplicateRoomException(String.format("Room %s already exists", name));
+ }
+ // special case: lobby uses the basic Room implementation; other rooms use GameRoom
+ Room room = Room.LOBBY.equalsIgnoreCase(nameCheck) ? new Room(name) : new GameRoom(name);
+ rooms.put(nameCheck, room);
+ info(String.format("Created new Room %s", name));
+ }
+
+ /**
+ * Moves a client (ServerThread) to the specified room, removing them from any
+ * previous room first.
+ *
+ * @param name room name the client should join
+ * @param client the client being moved
+ * @throws RoomNotFoundException if the target room does not exist
+ */
+ protected void joinRoom(String name, ServerThread client) throws RoomNotFoundException {
+ final String nameCheck = name.toLowerCase();
+ if (!rooms.containsKey(nameCheck)) {
+ throw new RoomNotFoundException(String.format("Room %s wasn't found", name));
+ }
+ Room currentRoom = client.getCurrentRoom();
+ if (currentRoom != null) {
+ info("Removing client from previous Room " + currentRoom.getName());
+ currentRoom.removeClient(client);
+ }
+ Room next = rooms.get(nameCheck);
+ next.addClient(client);
+ }
+
+ /**
+ * Returns a list of room names that contain the provided substring.
+ *
+ * @param roomQuery case-insensitive text used to filter room names
+ * @return a list of matching room names (up to 10, sorted alphabetically)
+ */
+ protected List listRooms(String roomQuery) {
+ final String nameCheck = roomQuery.toLowerCase();
+ return rooms.values().stream()
+ .filter(room -> room.getName().toLowerCase().contains(nameCheck)) // partial name matches
+ .map(room -> room.getName()) // convert Room to its name
+ .limit(10) // restrict result set size
+ .sorted() // sort names alphabetically
+ .collect(Collectors.toList()); // collect into a standard mutable List
+ }
+
+ /**
+ * Removes the specified room from the server registry.
+ *
+ * @param room the Room instance to deregister
+ */
+ protected void removeRoom(Room room) {
+ rooms.remove(room.getName().toLowerCase());
+ info(String.format("Removed room %s", room.getName()));
+ }
+
+ /**
+ *
+ * Note: Not a typical production use-case; mostly present as a sample.
+ *
+ * Relays a message from the given sender to every Room managed by the Server.
+ * The synchronized keyword ensures that only one thread executes this at
+ * a time, helping avoid concurrent modification problems.
+ *
+ * @param message text to distribute
+ * @param sender ServerThread (client) that originated the message, or null
+ * if the message is generated by the server itself
+ */
+ private synchronized void relayToAllRooms(ServerThread sender, String message) {
+ // Make sure any formatting or decoration of the message is done before this point
+ String senderString = sender == null ? "Server" : sender.getDisplayName();
+ // formattedMessage must be effectively final for use within the lambda below
+ final String formattedMessage = String.format("%s: %s", senderString, message);
+
+ // iterate over Rooms and forward the message through each one
+ rooms.values().forEach(room -> {
+ room.relay(sender, formattedMessage);
+ });
+ }
+
+ /**
+ * Public helper to broadcast a message out to every room.
+ * Primarily a demonstration method; may not be heavily used in practice.
+ *
+ * @param sender originator of the message (may be null for server-originated messages)
+ * @param message the text to be broadcast
+ */
+ public synchronized void broadcastMessageToAllRooms(ServerThread sender, String message) {
+ relayToAllRooms(sender, message);
+ }
+
+ public static void main(String[] args) {
+ LoggerUtil.INSTANCE.info("Server Starting");
+ Server server = Server.INSTANCE;
+ int port = 3000;
+ try {
+ port = Integer.parseInt(args[0]);
+ } catch (Exception e) {
+ // can safely ignore: either an index error or a parsing issue
+ // in either case, we fall back to the default port value defined above
+ }
+ server.start(port);
+ LoggerUtil.INSTANCE.warning("Server Stopped");
+ }
+
+}
diff --git a/Project/Server/ServerThread.java b/Project/Server/ServerThread.java
new file mode 100644
index 0000000..5ba4d0a
--- /dev/null
+++ b/Project/Server/ServerThread.java
@@ -0,0 +1,326 @@
+package Project.Server;
+
+import Project.Common.ConnectionPayload;
+import Project.Common.Constants;
+import Project.Common.LoggerUtil;
+import Project.Common.Payload;
+import Project.Common.PayloadType;
+import Project.Common.Phase;
+import Project.Common.PickPayload;
+import Project.Common.PointsPayload;
+import Project.Common.ReadyPayload;
+import Project.Common.RoomAction;
+import Project.Common.RoomResultPayload;
+import Project.Common.TextFX;
+import Project.Common.TextFX.Color;
+import java.net.Socket;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Server-side wrapper representing a single connected client.
+ */
+public class ServerThread extends BaseServerThread {
+ // callback used to notify listeners when this thread has finished initialization
+ private Consumer onInitializationComplete;
+
+ /**
+ * Convenience logger wrapper so we don't have to repeat the formatted output
+ * syntax everywhere.
+ *
+ * @param message text to log
+ */
+ @Override
+ protected void info(String message) {
+ LoggerUtil.INSTANCE
+ .info(TextFX.colorize(String.format("Thread[%s]: %s", this.getClientId(), message), Color.CYAN));
+ }
+
+ /**
+ * Wraps the socket for a single client and accepts a callback to notify when
+ * this ServerThread is fully initialized.
+ *
+ * @param myClient underlying client socket (must not be null)
+ * @param onInitializationComplete function to invoke once this object is ready
+ */
+ protected ServerThread(Socket myClient, Consumer onInitializationComplete) {
+ Objects.requireNonNull(myClient, "Client socket cannot be null");
+ Objects.requireNonNull(onInitializationComplete, "callback cannot be null");
+ info("ServerThread created");
+ // keep reference to the client connection
+ this.client = myClient;
+ // client id is assigned later by the Server callback
+ this.onInitializationComplete = onInitializationComplete;
+ }
+
+ // Start Send*() Methods
+
+ public boolean sendResetTurnStatus() {
+ ReadyPayload rp = new ReadyPayload();
+ rp.setPayloadType(PayloadType.RESET_TURN);
+ return sendToClient(rp);
+ }
+
+ public boolean sendTurnStatus(long clientId, boolean didTakeTurn) {
+ return sendTurnStatus(clientId, didTakeTurn, false);
+ }
+
+ public boolean sendTurnStatus(long clientId, boolean didTakeTurn, boolean quiet) {
+ // NOTE: using ReadyPayload here since it already carries the needed fields
+ // A real "turn" payload could be introduced later for more complex projects
+ ReadyPayload rp = new ReadyPayload();
+ rp.setPayloadType(quiet ? PayloadType.SYNC_TURN : PayloadType.TURN);
+ rp.setClientId(clientId);
+ rp.setReady(didTakeTurn);
+ return sendToClient(rp);
+ }
+
+ public boolean sendCurrentPhase(Phase phase) {
+ Payload p = new Payload();
+ p.setPayloadType(PayloadType.PHASE);
+ p.setMessage(phase.name());
+ return sendToClient(p);
+ }
+
+ public boolean sendResetReady() {
+ ReadyPayload rp = new ReadyPayload();
+ rp.setPayloadType(PayloadType.RESET_READY);
+ return sendToClient(rp);
+ }
+
+ public boolean sendReadyStatus(long clientId, boolean isReady) {
+ return sendReadyStatus(clientId, isReady, false);
+ }
+
+ /**
+ * Synchronizes the ready flag for a particular client id.
+ *
+ * @param clientId id of the player being updated
+ * @param isReady whether they are marked ready or not
+ * @param quiet if true, performs a silent sync without extra client output
+ * @return true if the send operation succeeded
+ */
+ public boolean sendReadyStatus(long clientId, boolean isReady, boolean quiet) {
+ ReadyPayload rp = new ReadyPayload();
+ rp.setClientId(clientId);
+ rp.setReady(isReady);
+ if (quiet) {
+ rp.setPayloadType(PayloadType.SYNC_READY);
+ }
+ return sendToClient(rp);
+ }
+
+ public boolean sendRooms(List rooms) {
+ RoomResultPayload rrp = new RoomResultPayload();
+ rrp.setRooms(rooms);
+ return sendToClient(rrp);
+ }
+
+ protected boolean sendDisconnect(long clientId) {
+ Payload payload = new Payload();
+ payload.setClientId(clientId);
+ payload.setPayloadType(PayloadType.DISCONNECT);
+ return sendToClient(payload);
+ }
+
+ /**
+ * Instructs the client to clear its local user list.
+ *
+ * @return true if the message is delivered successfully
+ */
+ protected boolean sendResetUserList() {
+ return sendClientInfo(Constants.DEFAULT_CLIENT_ID, null, RoomAction.JOIN);
+ }
+
+ /**
+ * Sends client identity info (id, name, join/leave status) to this client.
+ *
+ * @param clientId use -1 to indicate a reset or clear operation
+ * @param clientName name to associate with that id
+ * @param action RoomAction.JOIN or RoomAction.LEAVE
+ * @return true if the payload is sent without error
+ */
+ protected boolean sendClientInfo(long clientId, String clientName, RoomAction action) {
+ return sendClientInfo(clientId, clientName, action, false);
+ }
+
+ /**
+ * Sends client identity info (id, name, join/leave status) to this client.
+ *
+ * @param clientId use -1 to indicate a reset or clear operation
+ * @param clientName name belonging to the given id
+ * @param action RoomAction of JOIN or LEAVE
+ * @param isSync when true, uses a "silent" sync-type payload so the client
+ * doesn't show additional feedback
+ * @return true if the send completed successfully
+ */
+ protected boolean sendClientInfo(long clientId, String clientName, RoomAction action, boolean isSync) {
+ ConnectionPayload payload = new ConnectionPayload();
+ switch (action) {
+ case JOIN:
+ payload.setPayloadType(PayloadType.ROOM_JOIN);
+ break;
+ case LEAVE:
+ payload.setPayloadType(PayloadType.ROOM_LEAVE);
+ break;
+ default:
+ break;
+ }
+ if (isSync) {
+ payload.setPayloadType(PayloadType.SYNC_CLIENT);
+ }
+ payload.setClientId(clientId);
+ payload.setClientName(clientName);
+ return sendToClient(payload);
+ }
+
+ /**
+ * Sends this thread's current client id to the connected client.
+ * Serves as part of the connection handshake.
+ *
+ * @return true if the handshake payload is sent
+ */
+ protected boolean sendClientId() {
+ ConnectionPayload payload = new ConnectionPayload();
+ payload.setPayloadType(PayloadType.CLIENT_ID);
+ payload.setClientId(getClientId());
+ // Can be used for server-side name normalization or profanity filtering if needed
+ payload.setClientName(getClientName());
+ return sendToClient(payload);
+ }
+
+ /**
+ * Sends a chat/message payload back to the client.
+ *
+ * @param clientId id of the sender (could be another client or server)
+ * @param message text of the message
+ * @return true if the send is successful
+ */
+ protected boolean sendMessage(long clientId, String message) {
+ Payload payload = new Payload();
+ payload.setPayloadType(PayloadType.MESSAGE);
+ payload.setMessage(message);
+ payload.setClientId(clientId);
+ return sendToClient(payload);
+ }
+
+ protected boolean sendPointsUpdate(long clientId, int points) {
+ PointsPayload pp = new PointsPayload();
+ pp.setPayloadType(PayloadType.POINTS);
+ pp.setClientId(clientId);
+ pp.setPoints(points);
+ return sendToClient(pp);
+ }
+
+ // End Send*() Methods
+
+ @Override
+ protected void processPayload(Payload incoming) {
+
+ switch (incoming.getPayloadType()) {
+ case CLIENT_CONNECT:
+ setClientName(((ConnectionPayload) incoming).getClientName().trim());
+ break;
+
+ case DISCONNECT:
+ currentRoom.handleDisconnect(this);
+ break;
+
+ case MESSAGE:
+ currentRoom.handleMessage(this, incoming.getMessage());
+ break;
+
+ case REVERSE:
+ currentRoom.handleReverseText(this, incoming.getMessage());
+ break;
+
+ case ROOM_CREATE:
+ currentRoom.handleCreateRoom(this, incoming.getMessage());
+ break;
+
+ case ROOM_JOIN:
+ currentRoom.handleJoinRoom(this, incoming.getMessage());
+ break;
+
+ case ROOM_LEAVE:
+ currentRoom.handleJoinRoom(this, Room.LOBBY);
+ break;
+
+ case ROOM_LIST:
+ currentRoom.handleListRooms(this, incoming.getMessage());
+ break;
+
+ case READY:
+ // no additional fields are required; the type alone signals the intent
+ try {
+ // Game-specific behavior is handled in the GameRoom subclass
+ ((GameRoom) currentRoom).handleReady(this);
+ } catch (Exception e) {
+ sendMessage(Constants.DEFAULT_CLIENT_ID, "You must be in a GameRoom to do the ready check");
+ }
+ break;
+
+ case SCOREBOARD:
+ try {
+ ((GameRoom) currentRoom).handleScoreboard(this);
+ } catch (Exception e) {
+ sendMessage(Constants.DEFAULT_CLIENT_ID, "Unable to provide scoreboard");
+ }
+ break;
+ // rk975 - 11/26/25
+ // Handles a player's pick choice during the game.
+ // Expects a PickPayload containing the choice ("r", "p", or "s").
+ // Delegates the processing to the GameRoom's handlePick() method.
+
+ case PICK:
+ try {
+ PickPayload pp = (PickPayload) incoming;
+ LoggerUtil.INSTANCE.info(
+ String.format("Received pick from %s -> %s", pp.getClientId(), pp.getChoice()));
+ ((GameRoom) currentRoom).handlePick(this, pp.getChoice());
+ } catch (Exception e) {
+ sendMessage(Constants.DEFAULT_CLIENT_ID, "You must be in a GameRoom to pick");
+ }
+ break;
+
+ case TURN:
+ // like READY, this relies on the type to represent the action intent
+ try {
+ // delegate turn processing logic to the GameRoom
+ ((GameRoom) currentRoom).handleTurnAction(this, incoming.getMessage());
+ } catch (Exception e) {
+ sendMessage(Constants.DEFAULT_CLIENT_ID, "You must be in a GameRoom to do a turn");
+ }
+ break;
+
+ default:
+ LoggerUtil.INSTANCE.warning(TextFX.colorize("Unknown payload type received", Color.RED));
+ break;
+ }
+ }
+
+ // limited user data exposure helpers
+
+ protected boolean isReady() {
+ return this.user.isReady();
+ }
+
+ protected void setReady(boolean isReady) {
+ this.user.setReady(isReady);
+ }
+
+ protected boolean didTakeTurn() {
+ return this.user.didTakeTurn();
+ }
+
+ protected void setTookTurn(boolean tookTurn) {
+ this.user.setTookTurn(tookTurn);
+ }
+
+ @Override
+ protected void onInitialized() {
+ // once we've received and stored the client name, consider this thread ready
+ onInitializationComplete.accept(this);
+ }
+}
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..8f1ec7a
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+wd=$(pwd)
+cd $1
+# delete all .class files
+find . -name "*.class" -type f -delete
+find . -name "*.java" > sources.txt # generate list of files
+javac @sources.txt # compile from list of files
+rm sources.txt # cleanup
+cd "$wd"
\ No newline at end of file
diff --git a/run.sh b/run.sh
new file mode 100644
index 0000000..c0bbb96
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Convert input to lowercase
+input=$(echo "${2:-client}" | tr '[:upper:]' '[:lower:]')
+port=${3:-3000} # Default port to 3000 if not
+# Default debug mode to false
+debug=false
+debugArg=""
+# Check for -d flag
+if [[ " $@ " =~ " -d " ]]; then
+ debug=true
+fi
+if $debug; then
+ # Used for binding to vs code debug mode
+ debugArg="-agentlib:jdwp=transport=dt_socket,server=y,address=5005"
+ echo "Debug mode is ON"
+fi
+
+if [ "$input" = "server" ]; then
+ java $debugArg $1.Server.Server $port
+elif [ "$input" = "client" ]; then
+ java $debugArg $1.Client.Client
+ # In Milestone3 changes Client to ClientUI
+elif [ "$input" = "ui" ]; then
+ java $debugArg $1.Client.ClientUI
+ # Milestone 3's new entry point
+else
+ echo "Must specify client or server for MS2 or ui or server for MS3"
+fi
\ No newline at end of file