diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..0d55fbc Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b623380..f27857f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Compile Java code and tests run: | mkdir -p bin - find src -name "*.java" > sources.txt - javac -cp "lib/*" -d bin @sources.txt + find java-backend/src -name "*.java" > sources.txt + javac -cp "java-backend/lib/*" -d bin @sources.txt - name: Run JUnit tests - run: java -cp "bin:lib/*" org.junit.platform.console.ConsoleLauncher --scan-class-path + run: java -cp "bin:java-backend/lib/*" org.junit.platform.console.ConsoleLauncher --scan-class-path diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..024a38d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "java.project.sourcePaths": [ + "java-backend/src" + ], + "java.project.referencedLibraries": [ + "java-backend/lib/**/*.jar", + ] +} diff --git a/.gitignore b/java-backend/.gitignore similarity index 84% rename from .gitignore rename to java-backend/.gitignore index e6021e4..20ef7c1 100644 --- a/.gitignore +++ b/java-backend/.gitignore @@ -4,4 +4,5 @@ we-get-these-100s.iml latex/main.aux latex/main.log .DS_Store -main.out \ No newline at end of file +main.out +.env \ No newline at end of file diff --git a/java-backend/Dockerfile b/java-backend/Dockerfile new file mode 100644 index 0000000..67e52b8 --- /dev/null +++ b/java-backend/Dockerfile @@ -0,0 +1,53 @@ +# Use Eclipse Temurin as the builder (more secure and maintained) +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /app + +# Create lib directory first to avoid errors if it doesn't exist +RUN mkdir -p lib + +# Copy project source and dependencies +COPY src/ ./src/ +COPY lib/*.jar ./lib/ +COPY src/simulation_data.json ./simulation_data.json + +# Compile the Java sources. +# Adjust the classpath, output directory, and source files as needed. +RUN mkdir out && javac -d out -cp "./lib/*" $(find src -name "*.java") + +# Final stage: use a minimal JRE image for runtime +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +# Create a non-root user for security +RUN addgroup -g 1001 -S appgroup && \ + adduser -S appuser -u 1001 -G appgroup + +# Copy compiled classes and the lib folder from builder stage +COPY --from=builder /app/out/ ./out/ +COPY --from=builder /app/lib/ ./lib/ +COPY --from=builder /app/simulation_data.json ./simulation_data.json + +# Change ownership to the non-root user +RUN chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose the WebSocket port +EXPOSE 8080 + +# Add a simple healthcheck +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD exit 0 + +# Run in headless mode with server optimizations +CMD ["java", \ + "-Djava.awt.headless=true", \ + "-server", \ + "-XX:+UseContainerSupport", \ + "-XX:MinRAMPercentage=50", \ + "-XX:MaxRAMPercentage=80", \ + "-Dfile.encoding=UTF-8", \ + "-Duser.timezone=UTC", \ + "-cp", "./out:./lib/*", \ + "Main"] diff --git a/java-backend/lib/commons-pool2-2.12.1.jar b/java-backend/lib/commons-pool2-2.12.1.jar new file mode 100644 index 0000000..a8d1f10 Binary files /dev/null and b/java-backend/lib/commons-pool2-2.12.1.jar differ diff --git a/java-backend/lib/dotenv-java-3.1.0.jar b/java-backend/lib/dotenv-java-3.1.0.jar new file mode 100644 index 0000000..8f996f6 Binary files /dev/null and b/java-backend/lib/dotenv-java-3.1.0.jar differ diff --git a/lib/gson-2.12.0.jar b/java-backend/lib/gson-2.12.0.jar similarity index 100% rename from lib/gson-2.12.0.jar rename to java-backend/lib/gson-2.12.0.jar diff --git a/java-backend/lib/json-20250107.jar b/java-backend/lib/json-20250107.jar new file mode 100644 index 0000000..17afd9e Binary files /dev/null and b/java-backend/lib/json-20250107.jar differ diff --git a/lib/junit-jupiter-api-5.11.4.jar b/java-backend/lib/junit-jupiter-api-5.11.4.jar similarity index 100% rename from lib/junit-jupiter-api-5.11.4.jar rename to java-backend/lib/junit-jupiter-api-5.11.4.jar diff --git a/lib/junit-platform-console-standalone-1.11.4.jar b/java-backend/lib/junit-platform-console-standalone-1.11.4.jar similarity index 100% rename from lib/junit-platform-console-standalone-1.11.4.jar rename to java-backend/lib/junit-platform-console-standalone-1.11.4.jar diff --git a/lib/opentest4j-1.3.0.jar b/java-backend/lib/opentest4j-1.3.0.jar similarity index 100% rename from lib/opentest4j-1.3.0.jar rename to java-backend/lib/opentest4j-1.3.0.jar diff --git a/java-backend/lib/resilience4j-all-2.3.0.jar b/java-backend/lib/resilience4j-all-2.3.0.jar new file mode 100644 index 0000000..d6dec36 Binary files /dev/null and b/java-backend/lib/resilience4j-all-2.3.0.jar differ diff --git a/java-backend/lib/resilience4j-circuitbreaker-2.3.0.jar b/java-backend/lib/resilience4j-circuitbreaker-2.3.0.jar new file mode 100644 index 0000000..b3ce64d Binary files /dev/null and b/java-backend/lib/resilience4j-circuitbreaker-2.3.0.jar differ diff --git a/java-backend/lib/resilience4j-retry-2.3.0.jar b/java-backend/lib/resilience4j-retry-2.3.0.jar new file mode 100644 index 0000000..0de6fef Binary files /dev/null and b/java-backend/lib/resilience4j-retry-2.3.0.jar differ diff --git a/java-backend/lib/slf4j-api-2.0.16.jar b/java-backend/lib/slf4j-api-2.0.16.jar new file mode 100644 index 0000000..cbb5448 Binary files /dev/null and b/java-backend/lib/slf4j-api-2.0.16.jar differ diff --git a/java-backend/lib/slf4j-simple-2.0.17.jar b/java-backend/lib/slf4j-simple-2.0.17.jar new file mode 100644 index 0000000..9a7348e Binary files /dev/null and b/java-backend/lib/slf4j-simple-2.0.17.jar differ diff --git a/java-backend/src/Main.java b/java-backend/src/Main.java new file mode 100644 index 0000000..00bd606 --- /dev/null +++ b/java-backend/src/Main.java @@ -0,0 +1,22 @@ +import api.Connector; +import simulation.simulationData.Data; +import util.Parser; +import view.Engine; + +/** + * Main class to start the simulation through the Engine class. + * + * @author Mehmet Kutay Bozkurt and Anas Ahmed + * @version 1.0 + */ +public class Main { + private static final String PATH = System.getProperty("user.dir"); // The main directory of the project. + + public static void main(String[] args) { + Connector.getInstance().start(); // For use with the web API. + + // If you want to run the simulation without the web API, uncomment the following lines: + // Data.setSimulationData(Parser.parseSimulationData(Parser.getContentsOfFile(PATH + "/java-backend/src/simulation_data.json"))); + // new Engine(800, 600, 60).start(); + } +} diff --git a/java-backend/src/api/Connector.java b/java-backend/src/api/Connector.java new file mode 100644 index 0000000..961a241 --- /dev/null +++ b/java-backend/src/api/Connector.java @@ -0,0 +1,71 @@ +package api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A class to manage WebSocket connections and handle simulation control. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class Connector { + // The port for the WebSocket server + public static final int WEBSOCKET_PORT = 8080; + + // The logger to log messages. + private static Logger logger = LoggerFactory.getLogger(Connector.class); + + private static Connector instance; // Singleton instance + private final WebSocketHandler webSocketServer; // The unified WebSocket server. + + /** + * Private constructor for singleton pattern. + */ + private Connector() { + webSocketServer = new WebSocketHandler(WEBSOCKET_PORT); + } + + /** + * Get the singleton instance. + */ + public static synchronized Connector getInstance() { + if (instance == null) { + instance = new Connector(); + } + return instance; + } + + /** + * Start listening for WebSocket connections and messages. + */ + public void listen() { + try { + webSocketServer.start(); + } catch (Exception e) { + logger.error("WebSocket server failed.", e); + } + } + + /** + * Start the listener in a new thread. + */ + public void start() { + Thread t = new Thread(this::listen); + t.start(); + } + + /** + * Close the WebSocket server. + */ + public void close() { + webSocketServer.stop(); + } + + /** + * Get the WebSocket server instance. + */ + public WebSocketServer getWebSocketServer() { + return webSocketServer; + } +} diff --git a/java-backend/src/api/WebSocketConnection.java b/java-backend/src/api/WebSocketConnection.java new file mode 100644 index 0000000..f2233b9 --- /dev/null +++ b/java-backend/src/api/WebSocketConnection.java @@ -0,0 +1,267 @@ +package api; + +import java.io.IOException; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Represents a WebSocket connection to a client. + * Provides methods for sending different types of WebSocket frames + * and managing connection state. + * + * @author Mehmet Kutay Bozkurt + * @version 2.0 + */ +public class WebSocketConnection { + private final SocketChannel channel; + private final String id; + private final long connectTime; + private final AtomicLong lastPingTime = new AtomicLong(0); + private final AtomicLong lastPongTime = new AtomicLong(System.currentTimeMillis()); + private volatile boolean open = true; + + /** + * Create a new WebSocket connection wrapper. + * @param channel The underlying socket channel + */ + public WebSocketConnection(SocketChannel channel) { + this.channel = channel; + this.connectTime = System.currentTimeMillis(); + this.id = generateConnectionId(); + } + + /** + * Get the unique connection ID. + * @return The connection ID + */ + public String getId() { + return id; + } + + /** + * Get the underlying socket channel. + * @return The socket channel + */ + public SocketChannel getChannel() { + return channel; + } + + /** + * Get the remote address of the client. + * @return The remote socket address + */ + public SocketAddress getRemoteAddress() { + try { + return channel.getRemoteAddress(); + } catch (IOException e) { + return null; + } + } + + /** + * Check if the connection is open. + * @return true if the connection is open, false otherwise + */ + public boolean isOpen() { + return open && channel.isOpen() && channel.isConnected(); + } + + /** + * Get the connection time. + * @return The time when the connection was established + */ + public long getConnectTime() { + return connectTime; + } + + /** + * Get the last ping time. + * @return The time when the last ping was sent + */ + public long getLastPingTime() { + return lastPingTime.get(); + } + + /** + * Get the last pong time. + * @return The time when the last pong was received + */ + public long getLastPongTime() { + return lastPongTime.get(); + } + + /** + * Update the last pong time to current time. + */ + public void updateLastPongTime() { + lastPongTime.set(System.currentTimeMillis()); + } + + /** + * Send a text message to the client. + * @param message The text message to send + * @throws IOException If an I/O error occurs + */ + public void sendText(String message) throws IOException { + if (!isOpen()) { + throw new IOException("Connection is closed"); + } + sendFrame(message.getBytes("UTF-8"), WebSocketServer.OPCODE_TEXT); + } + + /** + * Send binary data to the client. + * @param data The binary data to send + * @throws IOException If an I/O error occurs + */ + public void sendBinary(byte[] data) throws IOException { + if (!isOpen()) { + throw new IOException("Connection is closed"); + } + sendFrame(data, WebSocketServer.OPCODE_BINARY); + } + + /** + * Send a ping frame to the client. + * @param payload The ping payload (optional, can be null) + * @throws IOException If an I/O error occurs + */ + public void sendPing(byte[] payload) throws IOException { + if (!isOpen()) { + throw new IOException("Connection is closed"); + } + byte[] data = payload != null ? payload : new byte[0]; + sendFrame(data, WebSocketServer.OPCODE_PING); + lastPingTime.set(System.currentTimeMillis()); + } + + /** + * Send a pong frame to the client. + * @param payload The pong payload (should match the ping payload) + * @throws IOException If an I/O error occurs + */ + public void sendPong(byte[] payload) throws IOException { + if (!isOpen()) { + throw new IOException("Connection is closed"); + } + byte[] data = payload != null ? payload : new byte[0]; + sendFrame(data, WebSocketServer.OPCODE_PONG); + } + + /** + * Send a close frame and close the connection. + * @param statusCode The close status code + * @param reason The close reason + * @throws IOException If an I/O error occurs + */ + public void close(int statusCode, String reason) throws IOException { + if (!isOpen()) { + return; + } + + open = false; + + try { + // Create close frame payload + byte[] reasonBytes = reason != null ? reason.getBytes("UTF-8") : new byte[0]; + byte[] payload = new byte[2 + reasonBytes.length]; + payload[0] = (byte) ((statusCode >> 8) & 0xFF); + payload[1] = (byte) (statusCode & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + + sendFrame(payload, WebSocketServer.OPCODE_CLOSE); + } finally { + channel.close(); + } + } + + /** + * Close the connection with default status code. + * @throws IOException If an I/O error occurs + */ + public void close() throws IOException { + close(WebSocketServer.CLOSE_NORMAL, "Normal closure"); + } + + /** + * Send a WebSocket frame with the specified payload and opcode. + * @param payload The frame payload + * @param opcode The frame opcode + * @throws IOException If an I/O error occurs + */ + private void sendFrame(byte[] payload, byte opcode) throws IOException { + ByteBuffer frame = createWebSocketFrame(payload, opcode); + synchronized (channel) { + while (frame.hasRemaining()) { + channel.write(frame); + } + } + } + + /** + * Create a WebSocket frame with the specified payload and opcode. + * Supports all payload sizes as per RFC 6455. + * @param payload The frame payload + * @param opcode The frame opcode + * @return The complete WebSocket frame as a ByteBuffer + */ + private ByteBuffer createWebSocketFrame(byte[] payload, byte opcode) { + long payloadLength = payload.length; + ByteBuffer frame; + + byte firstByte = (byte) (0x80 | opcode); // FIN=1, RSV=000, opcode + + if (payloadLength < 126) { + // Payload length fits in 7 bits + frame = ByteBuffer.allocate(2 + payload.length); + frame.put(firstByte); + frame.put((byte) payloadLength); + } else if (payloadLength < 65536) { + // Payload length fits in 16 bits + frame = ByteBuffer.allocate(4 + payload.length); + frame.put(firstByte); + frame.put((byte) 126); + frame.putShort((short) payloadLength); + } else { + // Payload length requires 64 bits + frame = ByteBuffer.allocate(10 + payload.length); + frame.put(firstByte); + frame.put((byte) 127); + frame.putLong(payloadLength); + } + + frame.put(payload); + frame.flip(); + return frame; + } + + /** + * Generate a unique connection ID. + * @return A unique connection identifier + */ + private String generateConnectionId() { + return String.format("ws-%d-%d", + System.currentTimeMillis(), + System.identityHashCode(this)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + WebSocketConnection that = (WebSocketConnection) obj; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return String.format("WebSocketConnection{id='%s', remote=%s, open=%s}", id, getRemoteAddress(), isOpen()); + } +} diff --git a/java-backend/src/api/WebSocketHandler.java b/java-backend/src/api/WebSocketHandler.java new file mode 100644 index 0000000..bec7348 --- /dev/null +++ b/java-backend/src/api/WebSocketHandler.java @@ -0,0 +1,109 @@ +package api; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.google.gson.Gson; + +import simulation.simulationData.Data; +import simulation.simulationData.SimulationData; +import util.Parser; +import view.Engine; + +/** + * Handles WebSocket messages for simulation control. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class WebSocketHandler extends WebSocketServer { + private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class); + + private Map engines = new ConcurrentHashMap<>(); + + public WebSocketHandler(int port) { + super(port); + } + + @Override + public void onOpen(WebSocketConnection connection) { + logger.info("WebSocket connection opened: {}", connection.getId()); + } + + @Override + public void onClose(WebSocketConnection connection, int statusCode, String reason) { + logger.info("WebSocket connection closed: {}", connection.getId()); + + Engine engine = engines.remove(connection.getId()); + + if (engine != null) { + logger.info("Client disconnected, stopping simulation engine"); + engine.stop(); + } + + if (getConnectionCount() == 0) { + logger.info("No clients connected, stopping all engines."); + for (Engine e : engines.values()) { + e.stop(); + } + engines.clear(); + } + } + + /** + * Handle incoming WebSocket message for simulation control. + * @param message The message received from the WebSocket client. + */ + @Override + public void onMessage(WebSocketConnection connection, String message) { + String formattedMessage = message.length() > 50 ? message.substring(0, 50) : message; + logger.info("Message received: {}...", formattedMessage); + + Gson g = new Gson(); + APISchema schema = g.fromJson(message, APISchema.class); + + if (!schema.type.equals("start_simulation")) { + logger.warn("Unknown message type: {}", schema.type); + return; + } + + Engine engine = engines.get(connection.getId()); + + if (engine != null) { + logger.warn("Simulation is already running. Stopping the current simulation before starting a new one."); + engine.stop(); + } + + // Get and set the simulation data: + SimulationData data = null; + try { + data = Parser.parseSimulationData(schema.data); + } catch (Exception e) { + logger.error("Failed to parse the simulation data.", e); + return; + } + Data.setSimulationData(data); + + // Start the simulation: + engine = new Engine(600, 600, 60, connection.getId()); + engines.put(connection.getId(), engine); + engine.start(); + } + + @Override + public void onBinaryMessage(WebSocketConnection connection, byte[] data) { + logger.warn("Binary messages are not supported in this WebSocket handler."); + } + + @Override + public void onError(WebSocketConnection connection, Exception error) { + logger.error("Error in WebSocket connection: {}", connection.getId(), error); + } + + private static class APISchema { + public String type; + public String data; + } +} diff --git a/java-backend/src/api/WebSocketServer.java b/java-backend/src/api/WebSocketServer.java new file mode 100644 index 0000000..4267bad --- /dev/null +++ b/java-backend/src/api/WebSocketServer.java @@ -0,0 +1,782 @@ +package api; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A comprehensive WebSocket server implementation following RFC 6455 specification. + * Supports all WebSocket frame types, proper handshakes, and handles various payload sizes. + * Provides customizable callback methods for connection lifecycle and message handling. + * (Doesn't handle fragmented messages, for now) + * + * @author Mehmet Kutay Bozkurt + * @version 2.0 + */ +public abstract class WebSocketServer { + private static final Logger logger = LoggerFactory.getLogger(WebSocketServer.class); + + // WebSocket Protocol Constants + private static final String WEBSOCKET_MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + private static final int MAX_FRAME_SIZE = 512 * 1024; // 512 KB default max frame size + private static final int BUFFER_SIZE = 8192; + + // WebSocket Opcodes (RFC 6455) + public static final byte OPCODE_CONTINUATION = 0x0; + public static final byte OPCODE_TEXT = 0x1; + public static final byte OPCODE_BINARY = 0x2; + public static final byte OPCODE_CLOSE = 0x8; + public static final byte OPCODE_PING = 0x9; + public static final byte OPCODE_PONG = 0xA; + + // WebSocket Close Status Codes (RFC 6455) + public static final int CLOSE_NORMAL = 1000; + public static final int CLOSE_GOING_AWAY = 1001; + public static final int CLOSE_PROTOCOL_ERROR = 1002; + public static final int CLOSE_UNSUPPORTED_DATA = 1003; + public static final int CLOSE_NO_STATUS = 1005; + public static final int CLOSE_ABNORMAL = 1006; + public static final int CLOSE_INVALID_PAYLOAD = 1007; + public static final int CLOSE_POLICY_VIOLATION = 1008; + public static final int CLOSE_MESSAGE_TOO_LARGE = 1009; + public static final int CLOSE_EXTENSION_REQUIRED = 1010; + public static final int CLOSE_INTERNAL_ERROR = 1011; + + // Server Configuration + private final int port; + private final int maxFrameSize; + + // Server State + private ServerSocketChannel serverChannel; + private Selector selector; + private volatile boolean running = false; + private ExecutorService executorService; + + // Client Management + private final Set connections = ConcurrentHashMap.newKeySet(); + private final Pattern handshakePattern = Pattern.compile("Sec-WebSocket-Key: (.+)"); + + /** + * Create a WebSocket server with default configuration. + * @param port The port to listen on + */ + public WebSocketServer(int port) { + this(port, MAX_FRAME_SIZE); + } + + /** + * Create a WebSocket server with custom configuration. + * @param port The port to listen on + * @param maxFrameSize Maximum frame size in bytes + * @param enablePingPong Whether to enable automatic ping/pong + * @param pingInterval Ping interval in milliseconds + */ + public WebSocketServer(int port, int maxFrameSize) { + this.port = port; + this.maxFrameSize = maxFrameSize; + this.executorService = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "WebSocket-Worker"); + t.setDaemon(true); + return t; + }); + } + + /** + * Called when a new client successfully completes the WebSocket handshake. + * Override this method to handle new connections. + * @param connection The new WebSocket connection + */ + abstract public void onOpen(WebSocketConnection connection); + + /** + * Called when a client disconnects or connection is closed. + * Override this method to handle client disconnections. + * @param connection The WebSocket connection that was closed + * @param statusCode The close status code + * @param reason The close reason + */ + abstract public void onClose(WebSocketConnection connection, int statusCode, String reason); + + /** + * Called when a text message is received from a client. + * Override this method to handle incoming text messages. + * @param connection The WebSocket connection that sent the message + * @param message The text message received + */ + abstract public void onMessage(WebSocketConnection connection, String message); + + /** + * Called when a binary message is received from a client. + * Override this method to handle incoming binary messages. + * @param connection The WebSocket connection that sent the message + * @param data The binary data received + */ + abstract public void onBinaryMessage(WebSocketConnection connection, byte[] data); + + /** + * Called when a ping frame is received from a client. + * Default implementation automatically sends a pong response. + * Override this method to customize ping handling. + * @param connection The WebSocket connection that sent the ping + * @param payload The ping payload + */ + public void onPing(WebSocketConnection connection, byte[] payload) { + logger.debug("Ping received from {}", connection.getRemoteAddress()); + try { + connection.sendPong(payload); + } catch (IOException e) { + logger.warn("Failed to send pong response", e); + onError(connection, e); + } + } + + /** + * Called when a pong frame is received from a client. + * Override this method to handle pong frames. + * @param connection The WebSocket connection that sent the pong + * @param payload The pong payload + */ + public void onPong(WebSocketConnection connection, byte[] payload) { + logger.debug("Pong received from {}", connection.getRemoteAddress()); + connection.updateLastPongTime(); + } + + /** + * Called when an error occurs with a client connection. + * Override this method to handle errors. + * @param connection The WebSocket connection that encountered an error + * @param error The exception that occurred + */ + abstract public void onError(WebSocketConnection connection, Exception error); + + /** + * Send a text message to a specific client. + * @param connection The target connection + * @param message The text message to send + * @throws IOException If an I/O error occurs + */ + public void sendMessage(WebSocketConnection connection, String message) throws IOException { + if (!connections.contains(connection)) { + throw new IllegalArgumentException("Connection is not managed by this server"); + } + connection.sendText(message); + } + + /** + * Send binary data to a specific client. + * @param connection The target connection + * @param data The binary data to send + * @throws IOException If an I/O error occurs + */ + public void sendBinaryMessage(WebSocketConnection connection, byte[] data) throws IOException { + if (!connections.contains(connection)) { + throw new IllegalArgumentException("Connection is not managed by this server"); + } + connection.sendBinary(data); + } + + /** + * Send a ping frame to a specific client. + * @param connection The target connection + * @param payload Optional ping payload + * @throws IOException If an I/O error occurs + */ + public void sendPing(WebSocketConnection connection, byte[] payload) throws IOException { + if (!connections.contains(connection)) { + throw new IllegalArgumentException("Connection is not managed by this server"); + } + connection.sendPing(payload); + } + + /** + * Close a specific connection gracefully. + * @param connection The connection to close + * @param statusCode The close status code + * @param reason The close reason + */ + public void closeConnection(WebSocketConnection connection, int statusCode, String reason) { + if (!connections.contains(connection)) { + return; + } + + try { + connection.close(statusCode, reason); + } catch (IOException e) { + logger.warn("Error closing connection {}: {}", connection.getId(), e.getMessage()); + } finally { + connections.remove(connection); + onClose(connection, statusCode, reason); + } + } + + /** + * Close a connection with default status code (1000 - Normal Closure). + * @param connection The connection to close + */ + public void closeConnection(WebSocketConnection connection) { + closeConnection(connection, CLOSE_NORMAL, "Normal closure"); + } + + /** + * Broadcast a text message to all connected clients. + * + * @param message The message to broadcast + */ + public void broadcast(String message) { + if (connections.isEmpty()) { + return; + } + + connections.forEach(connection -> { + try { + connection.sendText(message); + } catch (IOException e) { + logger.warn("Failed to send broadcast message to {}: {}", + connection.getId(), e.getMessage()); + handleConnectionError(connection, e); + } + }); + } + + /** + * Broadcast binary data to all connected clients. + * @param data The binary data to broadcast + */ + public void broadcastBinary(byte[] data) { + if (connections.isEmpty()) { + return; + } + + connections.forEach(connection -> { + try { + connection.sendBinary(data); + } catch (IOException e) { + logger.warn("Failed to send broadcast binary data to {}: {}", connection.getId(), e.getMessage()); + handleConnectionError(connection, e); + } + }); + } + + /** + * Handle connection errors by removing the connection and calling the error callback. + * @param connection The connection that encountered an error + * @param error The error that occurred + */ + private void handleConnectionError(WebSocketConnection connection, Exception error) { + connections.remove(connection); + onError(connection, error); + try { + connection.getChannel().close(); + } catch (IOException e) { + // Ignore close errors + } + } + + /** + * Start the WebSocket server on a new thread. + */ + public void start() { + if (running) { + logger.warn("Server is already running"); + return; + } + + Thread serverThread = new Thread(this::run, "WebSocket-Server"); + serverThread.setDaemon(false); + serverThread.start(); + + logger.info("WebSocket server starting on port {}", port); + } + + /** + * Stop the WebSocket server gracefully. + */ + public void stop() { + if (!running) { + return; + } + + running = false; + logger.info("Stopping WebSocket server..."); + + try { + // Close all client connections gracefully + closeAllConnections(CLOSE_GOING_AWAY, "Server shutting down"); + + if (selector != null) { + selector.wakeup(); + } + if (serverChannel != null) { + serverChannel.close(); + } + + // Shutdown executor service + if (executorService != null) { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + logger.info("WebSocket server stopped"); + } catch (IOException e) { + logger.error("Error stopping WebSocket server", e); + } + } + + /** + * Get the number of connected clients. + * @return The number of active connections + */ + public int getConnectionCount() { + return connections.size(); + } + + /** + * Get all active connections (read-only view). + * @return A set of all active connections + */ + public Set getConnections() { + return Set.copyOf(connections); + } + + /** + * Close all connections with the specified status code and reason. + * @param statusCode The close status code + * @param reason The close reason + */ + private void closeAllConnections(int statusCode, String reason) { + connections.forEach(connection -> { + try { + connection.close(statusCode, reason); + } catch (IOException e) { + logger.warn("Error closing connection {}: {}", connection.getId(), e.getMessage()); + } + }); + connections.clear(); + } + + /** + * Main server loop - runs in a separate thread. + */ + private void run() { + try { + initialiseServer(); + while (running) { + handleServerEvents(); + } + } catch (IOException e) { + logger.error("WebSocket server error", e); + } finally { + cleanup(); + } + } + + /** + * Initialise the server socket and selector. + * @throws IOException If an I/O error occurs + */ + private void initialiseServer() throws IOException { + serverChannel = ServerSocketChannel.open(); + serverChannel.configureBlocking(false); + serverChannel.bind(new InetSocketAddress(port)); + + selector = Selector.open(); + serverChannel.register(selector, SelectionKey.OP_ACCEPT); + + running = true; + logger.info("WebSocket server started on port {} (max frame size: {} bytes)", port, maxFrameSize); + } + + /** + * Handle server events (accept, read, write). + * @throws IOException If an I/O error occurs + */ + private void handleServerEvents() throws IOException { + selector.select(1000); // 1 second timeout + + Set selectedKeys = selector.selectedKeys(); + Iterator keyIterator = selectedKeys.iterator(); + + while (keyIterator.hasNext()) { + SelectionKey key = keyIterator.next(); + keyIterator.remove(); + + if (!key.isValid()) { + continue; + } + + try { + if (key.isAcceptable()) { + acceptConnection(); + } else if (key.isReadable()) { + handleRead(key); + } + } catch (Exception e) { + logger.warn("Error handling server event", e); + if (key.channel() instanceof SocketChannel) { + handleChannelError((SocketChannel) key.channel(), e); + } + } + } + } + + /** + * Accept a new client connection. + * @throws IOException If an I/O error occurs + */ + private void acceptConnection() throws IOException { + SocketChannel clientChannel = serverChannel.accept(); + if (clientChannel != null) { + clientChannel.configureBlocking(false); + clientChannel.register(selector, SelectionKey.OP_READ); + logger.debug("New client connection accepted from {}", clientChannel.getRemoteAddress()); + } + } + + /** + * Handle read events from client channels. + * @param key The selection key for the readable channel + */ + private void handleRead(SelectionKey key) { + SocketChannel clientChannel = (SocketChannel) key.channel(); + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE); + + try { + int bytesRead = clientChannel.read(buffer); + if (bytesRead == -1) { + // Client disconnected + handleClientDisconnection(clientChannel, CLOSE_NORMAL, "Client disconnected"); + key.cancel(); + return; + } + + if (bytesRead == 0) { + return; // No data available + } + + buffer.flip(); + byte[] data = new byte[buffer.limit()]; + buffer.get(data); + + WebSocketConnection connection = findConnectionByChannel(clientChannel); + + if (connection == null && isWebSocketHandshake(new String(data, "UTF-8"))) { + // Handle new WebSocket handshake + handleWebSocketHandshake(clientChannel, new String(data, "UTF-8")); + } else if (connection != null) { + // Handle WebSocket frame + handleWebSocketFrame(connection, data); + } else { + // Invalid data from non-WebSocket client + logger.warn("Received invalid data from non-WebSocket client: {}", clientChannel.getRemoteAddress()); + clientChannel.close(); + key.cancel(); + } + } catch (Exception e) { + logger.warn("Error reading from client {}: {}", getChannelAddress(clientChannel), e.getMessage()); + handleChannelError(clientChannel, e); + key.cancel(); + } + } + + /** + * Find a WebSocket connection by its underlying channel. + * @param channel The socket channel to find + * @return The WebSocket connection, or null if not found + */ + private WebSocketConnection findConnectionByChannel(SocketChannel channel) { + return connections.stream() + .filter(conn -> conn.getChannel().equals(channel)) + .findFirst() + .orElse(null); + } + + /** + * Handle WebSocket handshake for a new client. + * @param clientChannel The client socket channel + * @param request The HTTP request containing the handshake + * @throws IOException If an I/O error occurs + */ + private void handleWebSocketHandshake(SocketChannel clientChannel, String request) throws IOException { + try { + performHandshake(clientChannel, request); + WebSocketConnection connection = new WebSocketConnection(clientChannel); + connections.add(connection); + onOpen(connection); + logger.info("WebSocket handshake completed for client: {}", connection.getId()); + } catch (Exception e) { + logger.warn("WebSocket handshake failed for {}: {}", getChannelAddress(clientChannel), e.getMessage()); + clientChannel.close(); + throw e; + } + } + + /** + * Handle WebSocket frame data from a client. + * @param connection The WebSocket connection + * @param data The raw frame data + */ + private void handleWebSocketFrame(WebSocketConnection connection, byte[] data) { + try { + WebSocketFrame frame = parseWebSocketFrame(data); + if (frame == null) { + logger.warn("Invalid WebSocket frame from {}", connection.getId()); + return; + } + + switch (frame.opcode) { + case OPCODE_TEXT: + onMessage(connection, frame.getTextPayload()); + break; + case OPCODE_BINARY: + onBinaryMessage(connection, frame.payload); + break; + case OPCODE_CLOSE: + int statusCode = frame.getCloseStatusCode(); + String reason = frame.getCloseReason(); + logger.info("Received close frame from {}: {} - {}", connection.getId(), statusCode, reason); + handleClientDisconnection(connection.getChannel(), statusCode, reason); + break; + case OPCODE_PING: + onPing(connection, frame.payload); + break; + case OPCODE_PONG: + onPong(connection, frame.payload); + break; + default: + logger.warn("Unsupported WebSocket frame opcode: 0x{} from {}", Integer.toHexString(frame.opcode), connection.getId()); + break; + } + } catch (Exception e) { + logger.warn("Error handling WebSocket frame from {}: {}", connection.getId(), e.getMessage()); + handleConnectionError(connection, e); + } + } + + /** + * Handle client disconnection. + * @param clientChannel The client channel that disconnected + * @param statusCode The close status code + * @param reason The close reason + */ + private void handleClientDisconnection(SocketChannel clientChannel, int statusCode, String reason) { + WebSocketConnection connection = findConnectionByChannel(clientChannel); + if (connection != null) { + connections.remove(connection); + onClose(connection, statusCode, reason); + } + + try { + clientChannel.close(); + } catch (IOException e) { + // Ignore close errors + } + } + + /** + * Handle errors with a specific channel. + * @param clientChannel The channel that encountered an error + * @param error The error that occurred + */ + private void handleChannelError(SocketChannel clientChannel, Exception error) { + WebSocketConnection connection = findConnectionByChannel(clientChannel); + if (connection != null) { + handleConnectionError(connection, error); + } else { + try { + clientChannel.close(); + } catch (IOException e) { + // Ignore close errors + } + } + } + + /** + * Get the string representation of a channel's address. + * @param channel The socket channel + * @return The address string, or "unknown" if unavailable + */ + private String getChannelAddress(SocketChannel channel) { + try { + return channel.getRemoteAddress().toString(); + } catch (IOException e) { + return "unknown"; + } + } + + private boolean isWebSocketHandshake(String request) { + return request.contains("Upgrade: websocket") && + request.contains("Connection: Upgrade") && + request.contains("Sec-WebSocket-Key:"); + } + + private void performHandshake(SocketChannel clientChannel, String request) throws IOException { + Matcher matcher = handshakePattern.matcher(request); + + if (!matcher.find()) { + throw new IOException("Invalid WebSocket handshake"); + } + + String key = matcher.group(1).trim(); + String acceptKey = generateAcceptKey(key); + + String response = "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + acceptKey + "\r\n\r\n"; + + ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes()); + clientChannel.write(responseBuffer); + } + + private String generateAcceptKey(String key) { + try { + String combined = key + WEBSOCKET_MAGIC_STRING; + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + byte[] hash = digest.digest(combined.getBytes()); + return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + throw new RuntimeException("Failed to generate WebSocket accept key", e); + } + } + + private WebSocketFrame parseWebSocketFrame(byte[] data) { + if (data.length < 2) { + return null; + } + + byte firstByte = data[0]; + byte opcode = (byte) (firstByte & 0x0F); + + int payloadStart = 2; + int payloadLength = data[1] & 0x7F; + boolean masked = (data[1] & 0x80) != 0; + + byte rsv = (byte) ((firstByte >> 4) & 0x07); + if (rsv != 0) { + // Protocol error - reserved bits must be 0 + return null; + } + + if (payloadLength == 126) { + if (data.length < 4) return null; + payloadLength = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF); + payloadStart = 4; + } else if (payloadLength == 127) { + if (data.length < 10) return null; + long fullLength = 0; + for (int i = 2; i < 10; i++) { + fullLength = (fullLength << 8) | (data[i] & 0xFF); + } + payloadLength = (int) Math.min(fullLength, Integer.MAX_VALUE); + payloadStart = 10; + } + + if ((opcode & 0x8) != 0 && payloadLength > 125) { + // Protocol error - control frame too large + return null; + } + + if (masked) { + payloadStart += 4; // Skip mask key + } + + if (data.length < payloadStart + payloadLength) { + return null; + } + + byte[] payload = new byte[payloadLength]; + System.arraycopy(data, payloadStart, payload, 0, payloadLength); + + if (masked) { + byte[] mask = new byte[4]; + System.arraycopy(data, payloadStart - 4, mask, 0, 4); + + for (int i = 0; i < payloadLength; i++) { + payload[i] ^= mask[i % 4]; + } + } + + return new WebSocketFrame(opcode, payload); + } + + /** + * Inner class to represent a parsed WebSocket frame. + */ + private static class WebSocketFrame { + final byte opcode; + final byte[] payload; + + WebSocketFrame(byte opcode, byte[] payload) { + this.opcode = opcode; + this.payload = payload; + } + + String getTextPayload() { + return new String(payload); + } + + int getCloseStatusCode() { + if (payload.length >= 2) { + int code = ((payload[0] & 0xFF) << 8) | (payload[1] & 0xFF); + // Validate code is in allowed ranges + if (code < 1000 || code == 1004 || code == 1005 || + code == 1006 || (code >= 1012 && code <= 1014) || + code == 1015 || (code >= 1016 && code <= 1999)) { + return CLOSE_PROTOCOL_ERROR; + } + return code; + } + return CLOSE_NO_STATUS; + } + + String getCloseReason() { + if (payload.length > 2) { + return new String(payload, 2, payload.length - 2); + } + return ""; + } + } + + /** + * Clean up server resources. + */ + private void cleanup() { + try { + // Close all connections gracefully + closeAllConnections(CLOSE_GOING_AWAY, "Server shutting down"); + + if (selector != null) { + selector.close(); + } + if (serverChannel != null) { + serverChannel.close(); + } + } catch (IOException e) { + logger.error("Error during cleanup", e); + } + } +} diff --git a/src/entities/Plant.java b/java-backend/src/entities/Plant.java similarity index 100% rename from src/entities/Plant.java rename to java-backend/src/entities/Plant.java diff --git a/src/entities/PlantTest.java b/java-backend/src/entities/PlantTest.java similarity index 90% rename from src/entities/PlantTest.java rename to java-backend/src/entities/PlantTest.java index 3306666..791aee3 100644 --- a/src/entities/PlantTest.java +++ b/java-backend/src/entities/PlantTest.java @@ -8,6 +8,7 @@ import simulation.Field; import simulation.simulationData.*; +import util.Parser; import util.Vector; import genetics.PlantGenetics; @@ -22,7 +23,17 @@ class PlantTest { private Field field; @BeforeEach - void setUp() { + void setUp() throws Exception{ + final String PATH = System.getProperty("user.dir"); + SimulationData simulationData = null; + try { + simulationData = Parser.parseSimulationDataFromFile(PATH + "/java-backend/src/simulation_data.json"); + } catch (Exception e) { + System.out.println("Error reading simulation data."); + e.printStackTrace(); + throw e; + } + Data.setSimulationData(simulationData); this.genetics = Data.getPlantsData()[0].generateRandomGenetics(); this.field = new Field(100, 100); } diff --git a/src/entities/Predator.java b/java-backend/src/entities/Predator.java similarity index 100% rename from src/entities/Predator.java rename to java-backend/src/entities/Predator.java diff --git a/src/entities/Prey.java b/java-backend/src/entities/Prey.java similarity index 100% rename from src/entities/Prey.java rename to java-backend/src/entities/Prey.java diff --git a/src/entities/generic/Animal.java b/java-backend/src/entities/generic/Animal.java similarity index 100% rename from src/entities/generic/Animal.java rename to java-backend/src/entities/generic/Animal.java diff --git a/src/entities/generic/AnimalBehaviourController.java b/java-backend/src/entities/generic/AnimalBehaviourController.java similarity index 100% rename from src/entities/generic/AnimalBehaviourController.java rename to java-backend/src/entities/generic/AnimalBehaviourController.java diff --git a/src/entities/generic/AnimalBreedingController.java b/java-backend/src/entities/generic/AnimalBreedingController.java similarity index 100% rename from src/entities/generic/AnimalBreedingController.java rename to java-backend/src/entities/generic/AnimalBreedingController.java diff --git a/src/entities/generic/AnimalHungerController.java b/java-backend/src/entities/generic/AnimalHungerController.java similarity index 100% rename from src/entities/generic/AnimalHungerController.java rename to java-backend/src/entities/generic/AnimalHungerController.java diff --git a/src/entities/generic/AnimalMovementController.java b/java-backend/src/entities/generic/AnimalMovementController.java similarity index 100% rename from src/entities/generic/AnimalMovementController.java rename to java-backend/src/entities/generic/AnimalMovementController.java diff --git a/src/entities/generic/AnimalTest.java b/java-backend/src/entities/generic/AnimalTest.java similarity index 94% rename from src/entities/generic/AnimalTest.java rename to java-backend/src/entities/generic/AnimalTest.java index 6b4a2ac..d82b658 100644 --- a/src/entities/generic/AnimalTest.java +++ b/java-backend/src/entities/generic/AnimalTest.java @@ -10,6 +10,7 @@ import entities.*; import simulation.Field; import simulation.simulationData.*; +import util.Parser; import util.Vector; import genetics.AnimalGenetics; import genetics.Gender; @@ -26,7 +27,17 @@ class AnimalTest { private Field field; @BeforeEach - void setUp() { + void setUp() throws Exception { + final String PATH = System.getProperty("user.dir"); + SimulationData simulationData = null; + try { + simulationData = Parser.parseSimulationDataFromFile(PATH + "/java-backend/src/simulation_data.json"); + } catch (Exception e) { + System.out.println("Error reading simulation data."); + e.printStackTrace(); + throw e; + } + Data.setSimulationData(simulationData); this.genetics = Data.getPredatorsData()[0].generateRandomGenetics(); this.animal = new Predator(genetics, new Vector(50, 50)); this.field = new Field(100, 100); diff --git a/src/entities/generic/Entity.java b/java-backend/src/entities/generic/Entity.java similarity index 100% rename from src/entities/generic/Entity.java rename to java-backend/src/entities/generic/Entity.java diff --git a/src/entities/generic/EntityTest.java b/java-backend/src/entities/generic/EntityTest.java similarity index 93% rename from src/entities/generic/EntityTest.java rename to java-backend/src/entities/generic/EntityTest.java index 65bf4df..e79ee5a 100644 --- a/src/entities/generic/EntityTest.java +++ b/java-backend/src/entities/generic/EntityTest.java @@ -11,6 +11,8 @@ import entities.*; import genetics.AnimalGenetics; import simulation.simulationData.Data; +import simulation.simulationData.SimulationData; +import util.Parser; import util.Vector; /** @@ -24,7 +26,17 @@ class EntityTest { private AnimalGenetics genetics; @BeforeEach - void setUp() { + void setUp() throws Exception { + final String PATH = System.getProperty("user.dir"); + SimulationData simulationData = null; + try { + simulationData = Parser.parseSimulationDataFromFile(PATH + "/java-backend/src/simulation_data.json"); + } catch (Exception e) { + System.out.println("Error reading simulation data."); + e.printStackTrace(); + throw e; + } + Data.setSimulationData(simulationData); this.genetics = Data.getPredatorsData()[0].generateRandomGenetics(); this.animal = new Predator(genetics, new Vector(50, 50)); } diff --git a/src/genetics/AnimalGenetics.java b/java-backend/src/genetics/AnimalGenetics.java similarity index 100% rename from src/genetics/AnimalGenetics.java rename to java-backend/src/genetics/AnimalGenetics.java diff --git a/src/genetics/Gender.java b/java-backend/src/genetics/Gender.java similarity index 100% rename from src/genetics/Gender.java rename to java-backend/src/genetics/Gender.java diff --git a/src/genetics/Genetics.java b/java-backend/src/genetics/Genetics.java similarity index 100% rename from src/genetics/Genetics.java rename to java-backend/src/genetics/Genetics.java diff --git a/src/genetics/PlantGenetics.java b/java-backend/src/genetics/PlantGenetics.java similarity index 100% rename from src/genetics/PlantGenetics.java rename to java-backend/src/genetics/PlantGenetics.java diff --git a/src/genetics/mutation/AnimalMutator.java b/java-backend/src/genetics/mutation/AnimalMutator.java similarity index 100% rename from src/genetics/mutation/AnimalMutator.java rename to java-backend/src/genetics/mutation/AnimalMutator.java diff --git a/src/genetics/mutation/Mutator.java b/java-backend/src/genetics/mutation/Mutator.java similarity index 100% rename from src/genetics/mutation/Mutator.java rename to java-backend/src/genetics/mutation/Mutator.java diff --git a/src/genetics/mutation/PlantMutator.java b/java-backend/src/genetics/mutation/PlantMutator.java similarity index 100% rename from src/genetics/mutation/PlantMutator.java rename to java-backend/src/genetics/mutation/PlantMutator.java diff --git a/src/graphics/Display.java b/java-backend/src/graphics/Display.java similarity index 84% rename from src/graphics/Display.java rename to java-backend/src/graphics/Display.java index b1d384d..5621bd4 100644 --- a/src/graphics/Display.java +++ b/java-backend/src/graphics/Display.java @@ -2,7 +2,6 @@ import util.Vector; -import javax.swing.JFrame; import java.awt.*; /** @@ -14,30 +13,26 @@ * @version 1.0 */ public class Display { - private final RenderPanel renderPanel; // The panel to render to. + private final RenderPanel renderPanel; + private final int width; + private final int height; + private final String id; // Unique ID for this display instance /** * Constructor -- Create a new display with the specified screen width and height. */ - public Display(int screenWidth, int screenHeight) { - JFrame display = new JFrame("Window"); - renderPanel = new RenderPanel(screenWidth, screenHeight); - - display.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); - display.setResizable(false); - - display.add(renderPanel); - display.pack(); // Force correct sizing - - display.setVisible(true); - display.createBufferStrategy(2); + public Display(int screenWidth, int screenHeight, String id) { + this.id = id; + this.renderPanel = new RenderPanelWeb(screenWidth, screenHeight, id); + this.width = screenWidth; + this.height = screenHeight; } /** * Update the display to show the rendered graphics. */ public void update() { - renderPanel.repaint(); + renderPanel.update(); } /** @@ -124,6 +119,7 @@ public void drawArrow(int x1, int y1, double direction, double length, Color col } // Getters: - public int getWidth() { return renderPanel.getWidth(); } - public int getHeight() { return renderPanel.getHeight(); } + public int getWidth() { return width; } + public int getHeight() { return height; } + public String getId() { return id; } } diff --git a/java-backend/src/graphics/DisplayData.java b/java-backend/src/graphics/DisplayData.java new file mode 100644 index 0000000..f49e937 --- /dev/null +++ b/java-backend/src/graphics/DisplayData.java @@ -0,0 +1,40 @@ +package graphics; + +import java.util.HashMap; + +import graphics.methods.Method; + +/** + * Store the display data for the web app that will be hold in the + * Redis database. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DisplayData { + public int w; // Width of the display. + public int h; // Height of the display. + public HashMap d; // Data of all of the function calls. + + /** + * Constructor. + */ + public DisplayData(int width, int height) { + this.w = width; + this.h = height; + d = new HashMap<>(); + } + + /** + * Get the method from the data. + * @param name The name of the method. + * @param method Used as a fail safe if the method is not found. + * @return The method. + */ + public Method get(String name, Method method) { + if (d.get(name) == null) { + d.put(name, method); + } + return d.get(name); + } +} \ No newline at end of file diff --git a/java-backend/src/graphics/RenderPanel.java b/java-backend/src/graphics/RenderPanel.java new file mode 100644 index 0000000..e9682b2 --- /dev/null +++ b/java-backend/src/graphics/RenderPanel.java @@ -0,0 +1,28 @@ +package graphics; + +import java.awt.Color; + +/** + * Interface for rendering graphics to a panel. This panel can be + * a GUI panel or a web panel. + * + * @author Mehmet Kutay Bozkurt and Anas Ahmed + * @version 1.0 + */ +public interface RenderPanel { + public void fill(Color color); + + public void drawCircle(int x, int y, int radius, Color color); + + public void drawRect(int x, int y, int width, int height, Color color, boolean filled); + + public void drawEqualTriangle(int centerX, int centerY, int radius, Color color); + + public void drawText(String text, int fontSize, int x, int y, Color color); + + public void drawLine(int x1, int y1, int x2, int y2, Color color); + + public void drawTransparentRect(int x, int y, int width, int height, Color color, double alpha); + + public void update(); +} diff --git a/src/graphics/RenderPanel.java b/java-backend/src/graphics/RenderPanelGUI.java similarity index 89% rename from src/graphics/RenderPanel.java rename to java-backend/src/graphics/RenderPanelGUI.java index 2535de6..751f9d1 100644 --- a/src/graphics/RenderPanel.java +++ b/java-backend/src/graphics/RenderPanelGUI.java @@ -2,6 +2,8 @@ import java.awt.*; import java.awt.image.BufferedImage; + +import javax.swing.JFrame; import javax.swing.JPanel; /** @@ -12,7 +14,7 @@ * @author Anas Ahmed and Mehmet Kutay Bozkurt * @version 1.0 */ -public class RenderPanel extends JPanel { +public class RenderPanelGUI extends JPanel implements RenderPanel { private final Graphics2D g2; // Graphics context. private final BufferedImage surface; // The buffered image to draw to. @@ -21,11 +23,22 @@ public class RenderPanel extends JPanel { * @param screenWidth the screen width of the display (px). * @param screenHeight the screen height of the display (px). */ - public RenderPanel(int screenWidth, int screenHeight) { + public RenderPanelGUI(int screenWidth, int screenHeight) { setPreferredSize(new Dimension(screenWidth, screenHeight)); surface = new BufferedImage(screenWidth, screenHeight, BufferedImage.TYPE_INT_ARGB); g2 = (Graphics2D) surface.getGraphics(); // Get graphics context. + + JFrame display = new JFrame("Window"); + + display.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + display.setResizable(false); + + display.add(this); + display.pack(); // Force correct sizing + + display.setVisible(true); + display.createBufferStrategy(2); } /** @@ -129,6 +142,10 @@ public void drawTransparentRect(int x, int y, int width, int height, Color color g2.setComposite(originalComposite); } + public void update() { + repaint(); + } + /** * Called with every draw call, draws everything stored on the bufferedImage to the display. */ diff --git a/java-backend/src/graphics/RenderPanelWeb.java b/java-backend/src/graphics/RenderPanelWeb.java new file mode 100644 index 0000000..d2431a4 --- /dev/null +++ b/java-backend/src/graphics/RenderPanelWeb.java @@ -0,0 +1,104 @@ +package graphics; + +import java.awt.Color; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import api.Connector; + +import graphics.methods.*; + +/** + * Send the simulation data to WebSocket clients to update the web app. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class RenderPanelWeb implements RenderPanel { + private static Logger logger = LoggerFactory.getLogger(RenderPanelWeb.class); + + private DisplayData data; // The data to be sent to WebSocket clients. + private int index = 0; // The global index of the data. + private double lastTick = 0; // To calculate the delta time. + private final String id; // Unique ID for this render panel instance + + /** + * Constructor -- Create a new RenderPanelWeb object. + * @param width The width of the display. + * @param height The height of the display. + */ + public RenderPanelWeb(int width, int height, String id) { + data = new DisplayData(width, height); + this.id = id; + } + + public void fill(Color color) { + Method m = data.get("f", new Fill()); + ((Fill) m).add(index++, getArrayFromColor(color)); + } + + public void drawCircle(int x, int y, int radius, Color color) { + Method m = data.get("c", new DrawCircle()); + ((DrawCircle) m).add(index++, x, y, radius, getArrayFromColor(color)); + } + + public void drawRect(int x, int y, int width, int height, Color color, boolean filled) { + Method m = data.get("r", new DrawRect()); + ((DrawRect) m).add(index++, x, y, width, height, getArrayFromColor(color), filled); + } + + public void drawEqualTriangle(int centerX, int centerY, int radius, Color color) { + Method m = data.get("e", new DrawEqualTriangle()); + ((DrawEqualTriangle) m).add(index++, centerX, centerY, radius, getArrayFromColor(color)); + } + + public void drawText(String text, int fontSize, int x, int y, Color color) { + Method m = data.get("t", new DrawText()); + ((DrawText) m).add(index++, text, fontSize, x, y, getArrayFromColor(color)); + } + + public void drawLine(int x1, int y1, int x2, int y2, Color color) { + Method m = data.get("l", new DrawLine()); + ((DrawLine) m).add(index++, x1, y1, x2, y2, getArrayFromColor(color)); + } + + public void drawTransparentRect(int x, int y, int width, int height, Color color, double alpha) { + Method m = data.get("a", new DrawTransparentRect()); + ((DrawTransparentRect) m).add(index++, x, y, width, height, getArrayFromColor(color), alpha); + } + + /** + * Send all the stored data to WebSocket clients. + */ + public void update() { + double nowTime = System.nanoTime(); + if ((nowTime - lastTick) * 1e-6 > 50) { + logger.info("Time since last tick: " + (nowTime - lastTick) * 1e-6 + " ms"); + } + lastTick = nowTime; + + Gson g = new Gson(); + String j = g.toJson(data); + + Connector.getInstance().getWebSocketServer().getConnections().forEach(connection -> { + if (connection.getId().equals(id) && connection.isOpen()) { + try { + connection.sendText(j); + } catch (Exception e) { + logger.error("Failed to send data to WebSocket client: " + connection.getId(), e); + } + } + }); + data.d.clear(); + index = 0; + } + + /** + * Get the three-wide array from a Color object. + */ + private int[] getArrayFromColor(Color c) { + return new int[] { c.getRed(), c.getGreen(), c.getBlue() }; + } +} \ No newline at end of file diff --git a/java-backend/src/graphics/methods/DrawCircle.java b/java-backend/src/graphics/methods/DrawCircle.java new file mode 100644 index 0000000..e572f40 --- /dev/null +++ b/java-backend/src/graphics/methods/DrawCircle.java @@ -0,0 +1,19 @@ +package graphics.methods; + +/** + * Method to "draw a circle" on the screen. Holds the data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DrawCircle extends Method { + /** + * Add a draw circle method call to the data. + */ + public void add(int i, int x, int y, int r, int[] color) { + super.add(i, color, 7); + d.add(x); + d.add(y); + d.add(r); + } +} diff --git a/java-backend/src/graphics/methods/DrawEqualTriangle.java b/java-backend/src/graphics/methods/DrawEqualTriangle.java new file mode 100644 index 0000000..bfa511c --- /dev/null +++ b/java-backend/src/graphics/methods/DrawEqualTriangle.java @@ -0,0 +1,20 @@ +package graphics.methods; + +/** + * Method to "draw an equalateral triangle" on the screen. Holds the + * data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DrawEqualTriangle extends Method { + /** + * Add a draw equalateral triangle method call to the data. + */ + public void add(int i, int x, int y, int r, int[] color) { + super.add(i, color, 7); + d.add(x); + d.add(y); + d.add(r); + } +} diff --git a/java-backend/src/graphics/methods/DrawLine.java b/java-backend/src/graphics/methods/DrawLine.java new file mode 100644 index 0000000..8638dbc --- /dev/null +++ b/java-backend/src/graphics/methods/DrawLine.java @@ -0,0 +1,20 @@ +package graphics.methods; + +/** + * Method to "draw a line" on the screen. Holds the data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DrawLine extends Method { + /** + * Add a draw line method call to the data. + */ + public void add(int i, int x1, int y1, int x2, int y2, int[] color) { + super.add(i, color, 8); + d.add(x1); + d.add(y1); + d.add(x2); + d.add(y2); + } +} diff --git a/java-backend/src/graphics/methods/DrawRect.java b/java-backend/src/graphics/methods/DrawRect.java new file mode 100644 index 0000000..4023f76 --- /dev/null +++ b/java-backend/src/graphics/methods/DrawRect.java @@ -0,0 +1,21 @@ +package graphics.methods; + +/** + * Method to "draw a rectangle" on the screen. Holds the data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DrawRect extends Method { + /** + * Add a draw rectangle method call to the data. + */ + public void add(int i, int x, int y, int w, int h, int[] color, boolean filled) { + super.add(i, color, 9); + d.add(x); + d.add(y); + d.add(w); + d.add(h); + d.add(filled); + } +} diff --git a/java-backend/src/graphics/methods/DrawText.java b/java-backend/src/graphics/methods/DrawText.java new file mode 100644 index 0000000..2f7ab12 --- /dev/null +++ b/java-backend/src/graphics/methods/DrawText.java @@ -0,0 +1,20 @@ +package graphics.methods; + +/** + * Method to "draw text" on the screen. Holds the data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DrawText extends Method { + /** + * Add a draw text method call to the data. + */ + public void add(int i, String text, int size, int x, int y, int[] color) { + super.add(i, color, 8); + d.add(text); + d.add(size); + d.add(x); + d.add(y); + } +} diff --git a/java-backend/src/graphics/methods/DrawTransparentRect.java b/java-backend/src/graphics/methods/DrawTransparentRect.java new file mode 100644 index 0000000..ac96489 --- /dev/null +++ b/java-backend/src/graphics/methods/DrawTransparentRect.java @@ -0,0 +1,21 @@ +package graphics.methods; + +/** + * Method to "draw a transparent rectangle" on the screen. Holds the data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class DrawTransparentRect extends Method { + /** + * Add a draw transparent rectangle method call to the data. + */ + public void add(int i, int x, int y, int w, int h, int[] color, double a) { + super.add(i, color, 9); + d.add(x); + d.add(y); + d.add(w); + d.add(h); + d.add(a); + } +} diff --git a/java-backend/src/graphics/methods/Fill.java b/java-backend/src/graphics/methods/Fill.java new file mode 100644 index 0000000..3daff6b --- /dev/null +++ b/java-backend/src/graphics/methods/Fill.java @@ -0,0 +1,16 @@ +package graphics.methods; + +/** + * Method to "fill" on the screen. Holds the data for the method call. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public class Fill extends Method { + /** + * Add a fill method call to the data. + */ + public void add(int i, int[] colour) { + super.add(i, colour, 4); + } +} diff --git a/java-backend/src/graphics/methods/Method.java b/java-backend/src/graphics/methods/Method.java new file mode 100644 index 0000000..102704a --- /dev/null +++ b/java-backend/src/graphics/methods/Method.java @@ -0,0 +1,46 @@ +package graphics.methods; + +import java.util.ArrayList; +import java.util.List; + +/** + * Abstract class for all the methods that can be used to draw on the screen, + * such as circles, rectangles, lines, etc. + * + * @author Mehmet Kutay Bozkurt + * @version 1.0 + */ +public abstract class Method { + public List d = new ArrayList<>(); // Data of the method. + private transient int length; + + /** + * Add a new entity to the data. + * @param id The index of the entity. + * @param colour The colour of the entity. + */ + protected void add(int id, int[] colour, int length) { + this.length = length; + d.add(id); + d.add(colour[0]); + d.add(colour[1]); + d.add(colour[2]); + } + + /** + * Reverse the data. + */ + public void reverse() { + d = d.reversed(); + for (int i = 0; i < d.size(); i += length) { + int j = -(i % length) + length - 1; + swapIndicies(i, j); + } + } + + private void swapIndicies(int i, int j) { + Object tmp = d.get(i); + d.set(i, d.get(j)); + d.set(j, tmp); + } +} diff --git a/src/simulation/Field.java b/java-backend/src/simulation/Field.java similarity index 100% rename from src/simulation/Field.java rename to java-backend/src/simulation/Field.java diff --git a/src/simulation/FieldBuilder.java b/java-backend/src/simulation/FieldBuilder.java similarity index 100% rename from src/simulation/FieldBuilder.java rename to java-backend/src/simulation/FieldBuilder.java diff --git a/src/simulation/Simulator.java b/java-backend/src/simulation/Simulator.java similarity index 100% rename from src/simulation/Simulator.java rename to java-backend/src/simulation/Simulator.java diff --git a/src/simulation/environment/Environment.java b/java-backend/src/simulation/environment/Environment.java similarity index 100% rename from src/simulation/environment/Environment.java rename to java-backend/src/simulation/environment/Environment.java diff --git a/src/simulation/environment/Lightning.java b/java-backend/src/simulation/environment/Lightning.java similarity index 100% rename from src/simulation/environment/Lightning.java rename to java-backend/src/simulation/environment/Lightning.java diff --git a/src/simulation/environment/RainParticle.java b/java-backend/src/simulation/environment/RainParticle.java similarity index 100% rename from src/simulation/environment/RainParticle.java rename to java-backend/src/simulation/environment/RainParticle.java diff --git a/src/simulation/environment/TimeController.java b/java-backend/src/simulation/environment/TimeController.java similarity index 100% rename from src/simulation/environment/TimeController.java rename to java-backend/src/simulation/environment/TimeController.java diff --git a/src/simulation/environment/Weather.java b/java-backend/src/simulation/environment/Weather.java similarity index 100% rename from src/simulation/environment/Weather.java rename to java-backend/src/simulation/environment/Weather.java diff --git a/src/simulation/environment/WeatherController.java b/java-backend/src/simulation/environment/WeatherController.java similarity index 100% rename from src/simulation/environment/WeatherController.java rename to java-backend/src/simulation/environment/WeatherController.java diff --git a/src/simulation/quadTree/Circle.java b/java-backend/src/simulation/quadTree/Circle.java similarity index 100% rename from src/simulation/quadTree/Circle.java rename to java-backend/src/simulation/quadTree/Circle.java diff --git a/src/simulation/quadTree/QuadTree.java b/java-backend/src/simulation/quadTree/QuadTree.java similarity index 100% rename from src/simulation/quadTree/QuadTree.java rename to java-backend/src/simulation/quadTree/QuadTree.java diff --git a/src/simulation/quadTree/Rectangle.java b/java-backend/src/simulation/quadTree/Rectangle.java similarity index 100% rename from src/simulation/quadTree/Rectangle.java rename to java-backend/src/simulation/quadTree/Rectangle.java diff --git a/src/simulation/simulationData/AnimalData.java b/java-backend/src/simulation/simulationData/AnimalData.java similarity index 100% rename from src/simulation/simulationData/AnimalData.java rename to java-backend/src/simulation/simulationData/AnimalData.java diff --git a/src/simulation/simulationData/Data.java b/java-backend/src/simulation/simulationData/Data.java similarity index 83% rename from src/simulation/simulationData/Data.java rename to java-backend/src/simulation/simulationData/Data.java index 40eeb0b..39fbe88 100644 --- a/src/simulation/simulationData/Data.java +++ b/java-backend/src/simulation/simulationData/Data.java @@ -1,18 +1,21 @@ package simulation.simulationData; -import util.Parser; - /** - * A class to store all the data of the simulation. + * A class to store all and only the data of the simulation. * * @author Mehmet Kutay Bozkurt and Anas Ahmed * @version 1.0 */ public class Data { - public static final String PATH = System.getProperty("user.dir"); // The main directory of the project. + private static SimulationData simulationData = null; // The data of the simulation. - // The data of the simulation: - private static final SimulationData simulationData = Parser.parseSimulationData(Parser.getContentsOfFile(PATH + "/src/simulation_data.json")); + /** + * Setter to be used to parse and set the simulation data. + * @param data The data of the simulation to store. + */ + public static void setSimulationData(SimulationData data) { + simulationData = data; + } // Getters: public static AnimalData[] getPreysData() { return simulationData.preysData; } diff --git a/src/simulation/simulationData/EntityData.java b/java-backend/src/simulation/simulationData/EntityData.java similarity index 100% rename from src/simulation/simulationData/EntityData.java rename to java-backend/src/simulation/simulationData/EntityData.java diff --git a/src/simulation/simulationData/PlantData.java b/java-backend/src/simulation/simulationData/PlantData.java similarity index 100% rename from src/simulation/simulationData/PlantData.java rename to java-backend/src/simulation/simulationData/PlantData.java diff --git a/src/simulation/simulationData/SimulationData.java b/java-backend/src/simulation/simulationData/SimulationData.java similarity index 100% rename from src/simulation/simulationData/SimulationData.java rename to java-backend/src/simulation/simulationData/SimulationData.java diff --git a/src/simulation_data.json b/java-backend/src/simulation_data.json similarity index 100% rename from src/simulation_data.json rename to java-backend/src/simulation_data.json diff --git a/java-backend/src/util/Parser.java b/java-backend/src/util/Parser.java new file mode 100644 index 0000000..561db3d --- /dev/null +++ b/java-backend/src/util/Parser.java @@ -0,0 +1,70 @@ +package util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import simulation.simulationData.SimulationData; + +/** + * A class to parse JSON data from files. It can get the contents of a file as a String, + * and parse the contents as either AnimalData or PlantData. + * + * @author Mehmet Kutay Bozkurt and Anas Ahmed + * @version 1.0 + */ +public class Parser { + /** + * Serialise simulation data from some JSON content. + * @param jsonContent The JSON content. + * @return The simulation data parsed, serialised as the SimulationData class. + */ + public static SimulationData parseSimulationData(String jsonContent) throws JsonSyntaxException { + Gson g = new Gson(); + SimulationData simulationData = null; + try { + simulationData = g.fromJson(jsonContent, SimulationData.class); + } catch (JsonSyntaxException e) { + System.out.println("Error parsing JSON content: " + jsonContent); + e.printStackTrace(); + throw e; + } + return simulationData; + } + + /** + * Get the contents of a file as a String. + * @param fileName The name of the file. + * @return The contents of the file. + */ + public static String getContentsOfFile(String fileName) throws IOException { + String contents = null; + try { + contents = new String(Files.readAllBytes(Paths.get(fileName))); + } catch (IOException e) { + System.out.println("Error reading file: " + fileName); + e.printStackTrace(); + throw e; + } + return contents; + } + + public static SimulationData parseSimulationDataFromFile(String fileName) throws IOException, JsonSyntaxException { + String contents = null; + try { + contents = getContentsOfFile(fileName); + } catch (IOException e) { + throw e; + } + SimulationData simulationData = null; + try { + simulationData = parseSimulationData(contents); + } catch (JsonSyntaxException e) { + throw e; + } + return simulationData; + } +} diff --git a/src/util/Utility.java b/java-backend/src/util/Utility.java similarity index 100% rename from src/util/Utility.java rename to java-backend/src/util/Utility.java diff --git a/src/util/Vector.java b/java-backend/src/util/Vector.java similarity index 100% rename from src/util/Vector.java rename to java-backend/src/util/Vector.java diff --git a/src/view/Clock.java b/java-backend/src/view/Clock.java similarity index 100% rename from src/view/Clock.java rename to java-backend/src/view/Clock.java diff --git a/src/view/Engine.java b/java-backend/src/view/Engine.java similarity index 89% rename from src/view/Engine.java rename to java-backend/src/view/Engine.java index e534a63..dac01f4 100644 --- a/src/view/Engine.java +++ b/java-backend/src/view/Engine.java @@ -21,6 +21,7 @@ public class Engine { private final Display display; // The GUI display. private final Simulator simulator; // The simulation. private final Clock clock; // Clock to keep track of time. + private final String id; // Unique ID for this engine instance. private boolean running = false; // Whether the simulation is running. /** @@ -30,19 +31,20 @@ public class Engine { */ private final double fieldScaleFactor; // Scales the field size up/down, so field size doesn't have to be screen size. - /** + /** * Constructor - Create an engine to run the simulation. * @param displayWidth The width of the GUI display. * @param displayHeight The height of the GUI display. * @param fps FPS to run the simulation at. */ - public Engine(int displayWidth, int displayHeight, int fps) { + public Engine(int displayWidth, int displayHeight, int fps, String id) { fieldScaleFactor = Data.getFieldScaleFactor(); int fieldWidth = (int) (displayWidth * fieldScaleFactor); int fieldHeight = (int) (displayHeight * fieldScaleFactor); + this.id = id; simulator = new Simulator(fieldWidth, fieldHeight); - display = new Display(displayWidth, displayHeight); + display = new Display(displayWidth, displayHeight, id); clock = new Clock(fps); } @@ -118,4 +120,18 @@ public void start() { Thread t = new Thread(this::run); t.start(); } + + /** + * Stop the simulation. + */ + public void stop() { + running = false; + } + + /** + * @return ID of this engine instance. + */ + public String getId() { + return id; + } } diff --git a/src/Main.java b/src/Main.java deleted file mode 100644 index a98599f..0000000 --- a/src/Main.java +++ /dev/null @@ -1,17 +0,0 @@ -import view.Engine; - -/** - * Main class to start the simulation through the Engine class. - * - * @author Mehmet Kutay Bozkurt and Anas Ahmed - * @version 1.0 - */ -public class Main { - public static void main(String[] args) { - int fps = 60; - int width = 600; - int height = 600; - Engine engine = new Engine(width, height, fps); - engine.start(); - } -} diff --git a/src/util/Parser.java b/src/util/Parser.java deleted file mode 100644 index 98d2477..0000000 --- a/src/util/Parser.java +++ /dev/null @@ -1,43 +0,0 @@ -package util; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -import com.google.gson.Gson; - -import simulation.simulationData.SimulationData; - -/** - * A class to parse JSON data from files. It can get the contents of a file as a String, - * and parse the contents as either AnimalData or PlantData. - * - * @author Mehmet Kutay Bozkurt and Anas Ahmed - * @version 1.0 - */ -public class Parser { - /** - * Serialise simulation data from some JSON content. - * @param jsonContent The JSON content. - * @return The simulation data parsed, serialised as the SimulationData class. - */ - public static SimulationData parseSimulationData(String jsonContent) { - Gson g = new Gson(); - return g.fromJson(jsonContent, SimulationData.class); - } - - /** - * Get the contents of a file as a String. - * @param fileName The name of the file. - * @return The contents of the file. - */ - public static String getContentsOfFile(String fileName) { - try { - return new String(Files.readAllBytes(Paths.get(fileName))); - } catch (IOException e) { - System.out.println("Error reading file: " + fileName); - e.printStackTrace(); - return null; - } - } -}