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