diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 215ca2a..028e10f 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 # Fix for incremental formatting - uses: actions/setup-java@v5 with: - java-version: '24' + java-version: '25' distribution: 'temurin' cache: maven - name: Run Format Check @@ -30,12 +30,12 @@ jobs: needs: formatting-check strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest] #windows-latest, macos-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-java@v5 with: - java-version: '24' + java-version: '25' distribution: 'temurin' cache: maven - name: Run Unittests diff --git a/.gitignore b/.gitignore index 323576f..b561d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,11 @@ nb-configuration.xml # Ignore Gradle build output directory build + +############################## +## Hanze +############################## +newgamesver-release-V1.jar +server.properties +gameserver.log.* +gameserver.log \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index e5bfff7..46f4d3b 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -2,8 +2,11 @@ aosp + clid dcompile errorprone + flushnl + gaaf gamelist playerlist tictactoe diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..c168b80 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/pom.xml b/app/pom.xml index ca89a53..17ed5af 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -6,6 +6,7 @@ 0.1 + org.toop.Main 25 25 @@ -13,6 +14,12 @@ + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + org.toop pism_framework @@ -46,6 +53,41 @@ UTF-8 + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + origin/main + + + + + + .gitattributes + .gitignore + + + + + + true + 4 + + + + + + + 1.28.0 + + true + true + + + + \ No newline at end of file diff --git a/app/src/main/java/org/toop/Main.java b/app/src/main/java/org/toop/Main.java index 9d9bbfb..0f92ca8 100644 --- a/app/src/main/java/org/toop/Main.java +++ b/app/src/main/java/org/toop/Main.java @@ -1,6 +1,9 @@ package org.toop; import org.toop.app.App; +import org.toop.framework.asset.AssetLoader; +import org.toop.framework.asset.AssetManager; +import org.toop.framework.audio.SoundManager; import org.toop.framework.networking.NetworkingClientManager; import org.toop.framework.networking.NetworkingInitializationException; @@ -11,6 +14,8 @@ public class Main { } private static void initSystems() throws NetworkingInitializationException { - new NetworkingClientManager(); + AssetManager.loadAssets(new AssetLoader("app/src/main/resources/assets")); + new Thread(NetworkingClientManager::new).start(); + new Thread(SoundManager::new).start(); } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/toop/app/menu/MainMenu.java b/app/src/main/java/org/toop/app/menu/MainMenu.java index 4a01ca9..b378adf 100644 --- a/app/src/main/java/org/toop/app/menu/MainMenu.java +++ b/app/src/main/java/org/toop/app/menu/MainMenu.java @@ -8,6 +8,9 @@ import org.toop.local.AppContext; import java.util.Locale; import java.util.ResourceBundle; +import org.toop.framework.asset.AssetManager; +import org.toop.framework.asset.resources.CssAsset; +import org.toop.framework.asset.resources.ImageAsset; public final class MainMenu extends Menu { private Locale currentLocale = AppContext.getLocale(); @@ -22,17 +25,27 @@ public final class MainMenu extends Menu { final Button battleship = createButton(resourceBundle.getString("mainMenuSelectBattleship"), () -> {}); final Button other = createButton(resourceBundle.getString("mainMenuSelectOther"), () -> {}); - final VBox gamesBox = new VBox(tictactoe, reversi, sudoku, background, other); + final VBox gamesBox = new VBox(tictactoe, reversi, sudoku, battleship, other); gamesBox.setAlignment(Pos.TOP_CENTER); final Button credits = createButton(resourceBundle.getString("mainMenuSelectCredits"), () -> {}); final Button options = createButton(resourceBundle.getString("mainMenuSelectOptions"), () -> {}); final Button quit = createButton(resourceBundle.getString("mainMenuSelectQuit"), () -> {}); - final VBox creditsBox = new VBox(10, credits, options, quit); + final VBox creditsBox = new VBox(credits, options, quit); creditsBox.setAlignment(Pos.BOTTOM_CENTER); + VBox grid = new VBox(20, gamesBox, creditsBox); + grid.setAlignment(Pos.CENTER); + + ImageAsset backgroundImage = (ImageAsset) AssetManager.getByName("background.jpg").getResource(); + ImageView background = new ImageView(backgroundImage.getImage()); + background.setPreserveRatio(false); + background.fitWidthProperty().bind(grid.widthProperty()); + background.fitHeightProperty().bind(grid.heightProperty()); + pane = new StackPane(background, grid); - pane.getStylesheets().add(getClass().getResource("/style/main.css").toExternalForm()); + CssAsset css = (CssAsset) AssetManager.getByName("main.css").getResource(); + pane.getStylesheets().add(css.getUrl()); } } \ No newline at end of file diff --git a/app/src/main/java/org/toop/app/menu/QuitMenu.java b/app/src/main/java/org/toop/app/menu/QuitMenu.java index f57d592..150c687 100644 --- a/app/src/main/java/org/toop/app/menu/QuitMenu.java +++ b/app/src/main/java/org/toop/app/menu/QuitMenu.java @@ -8,6 +8,8 @@ import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.Text; import org.toop.app.App; +import org.toop.framework.asset.AssetManager; +import org.toop.framework.asset.resources.CssAsset; import org.toop.local.AppContext; import java.util.Locale; @@ -49,6 +51,7 @@ public final class QuitMenu extends Menu { StackPane.setAlignment(box, Pos.CENTER); pane = modalContainer; - pane.getStylesheets().add(getClass().getResource("/style/quit.css").toExternalForm()); + CssAsset css = (CssAsset) AssetManager.getByName("quit.css").getResource(); + pane.getStylesheets().add(css.getUrl()); } } \ No newline at end of file diff --git a/app/src/main/java/org/toop/tictactoe/LocalTicTacToe.java b/app/src/main/java/org/toop/tictactoe/LocalTicTacToe.java new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/org/toop/tictactoe/gui/UIGameBoard.java b/app/src/main/java/org/toop/tictactoe/gui/UIGameBoard.java new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/resources/assets/audio/dramatic.wav b/app/src/main/resources/assets/audio/dramatic.wav new file mode 100644 index 0000000..e57fbe7 Binary files /dev/null and b/app/src/main/resources/assets/audio/dramatic.wav differ diff --git a/app/src/main/resources/assets/audio/hitsound0.wav b/app/src/main/resources/assets/audio/hitsound0.wav new file mode 100644 index 0000000..da99370 Binary files /dev/null and b/app/src/main/resources/assets/audio/hitsound0.wav differ diff --git a/app/src/main/resources/assets/audio/hitsound1.wav b/app/src/main/resources/assets/audio/hitsound1.wav new file mode 100644 index 0000000..0f2ab26 Binary files /dev/null and b/app/src/main/resources/assets/audio/hitsound1.wav differ diff --git a/app/src/main/resources/assets/audio/mainmenu.wav b/app/src/main/resources/assets/audio/mainmenu.wav new file mode 100644 index 0000000..c0c3e25 Binary files /dev/null and b/app/src/main/resources/assets/audio/mainmenu.wav differ diff --git a/app/src/main/resources/assets/audio/sadtrombone.wav b/app/src/main/resources/assets/audio/sadtrombone.wav new file mode 100644 index 0000000..7eb18e6 Binary files /dev/null and b/app/src/main/resources/assets/audio/sadtrombone.wav differ diff --git a/app/src/main/resources/assets/audio/scawymusic.wav b/app/src/main/resources/assets/audio/scawymusic.wav new file mode 100644 index 0000000..9626b59 Binary files /dev/null and b/app/src/main/resources/assets/audio/scawymusic.wav differ diff --git a/app/src/main/resources/assets/audio/suspensful.wav b/app/src/main/resources/assets/audio/suspensful.wav new file mode 100644 index 0000000..0d5ef27 Binary files /dev/null and b/app/src/main/resources/assets/audio/suspensful.wav differ diff --git a/app/src/main/resources/assets/audio/testsound.wav b/app/src/main/resources/assets/audio/testsound.wav new file mode 100644 index 0000000..cf2a32f Binary files /dev/null and b/app/src/main/resources/assets/audio/testsound.wav differ diff --git a/app/src/main/resources/assets/audio/winsound.wav b/app/src/main/resources/assets/audio/winsound.wav new file mode 100644 index 0000000..d72a386 Binary files /dev/null and b/app/src/main/resources/assets/audio/winsound.wav differ diff --git a/app/src/main/resources/image/background.jpg b/app/src/main/resources/assets/image/background.jpg similarity index 100% rename from app/src/main/resources/image/background.jpg rename to app/src/main/resources/assets/image/background.jpg diff --git a/app/src/main/resources/image/game/battleship.png b/app/src/main/resources/assets/image/battleship.png similarity index 100% rename from app/src/main/resources/image/game/battleship.png rename to app/src/main/resources/assets/image/battleship.png diff --git a/app/src/main/resources/image/icon.png b/app/src/main/resources/assets/image/icon.png similarity index 100% rename from app/src/main/resources/image/icon.png rename to app/src/main/resources/assets/image/icon.png diff --git a/app/src/main/resources/image/game/other.png b/app/src/main/resources/assets/image/other.png similarity index 100% rename from app/src/main/resources/image/game/other.png rename to app/src/main/resources/assets/image/other.png diff --git a/app/src/main/resources/image/game/reversi.png b/app/src/main/resources/assets/image/reversi.png similarity index 100% rename from app/src/main/resources/image/game/reversi.png rename to app/src/main/resources/assets/image/reversi.png diff --git a/app/src/main/resources/image/game/sudoku.png b/app/src/main/resources/assets/image/sudoku.png similarity index 100% rename from app/src/main/resources/image/game/sudoku.png rename to app/src/main/resources/assets/image/sudoku.png diff --git a/app/src/main/resources/image/game/tictactoe.png b/app/src/main/resources/assets/image/tictactoe.png similarity index 100% rename from app/src/main/resources/image/game/tictactoe.png rename to app/src/main/resources/assets/image/tictactoe.png diff --git a/app/src/main/resources/style/main.css b/app/src/main/resources/assets/style/main.css similarity index 100% rename from app/src/main/resources/style/main.css rename to app/src/main/resources/assets/style/main.css diff --git a/app/src/main/resources/style/quit.css b/app/src/main/resources/assets/style/quit.css similarity index 100% rename from app/src/main/resources/style/quit.css rename to app/src/main/resources/assets/style/quit.css diff --git a/app/src/main/resources/style/style.css b/app/src/main/resources/assets/style/style.css similarity index 100% rename from app/src/main/resources/style/style.css rename to app/src/main/resources/assets/style/style.css diff --git a/app/src/main/resources/assets/text/test.txt b/app/src/main/resources/assets/text/test.txt new file mode 100644 index 0000000..9e44f93 --- /dev/null +++ b/app/src/main/resources/assets/text/test.txt @@ -0,0 +1 @@ +Super gaaf! \ No newline at end of file diff --git a/framework/pom.xml b/framework/pom.xml index e924481..5e84787 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -13,6 +13,12 @@ + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + io.netty netty-all @@ -90,6 +96,19 @@ 4.0.0 + + org.openjfx + javafx-controls + 25 + + + + + org.reflections + reflections + 0.10.2 + + @@ -123,6 +142,41 @@ + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + origin/main + + + + + + .gitattributes + .gitignore + + + + + + true + 4 + + + + + + + 1.28.0 + + true + true + + + + diff --git a/framework/src/main/java/org/toop/framework/eventbus/SnowflakeGenerator.java b/framework/src/main/java/org/toop/framework/SnowflakeGenerator.java similarity index 57% rename from framework/src/main/java/org/toop/framework/eventbus/SnowflakeGenerator.java rename to framework/src/main/java/org/toop/framework/SnowflakeGenerator.java index c75acba..a48a8a7 100644 --- a/framework/src/main/java/org/toop/framework/eventbus/SnowflakeGenerator.java +++ b/framework/src/main/java/org/toop/framework/SnowflakeGenerator.java @@ -1,10 +1,12 @@ -package org.toop.framework.eventbus; +package org.toop.framework; +import java.net.NetworkInterface; +import java.time.Instant; +import java.util.Collections; import java.util.concurrent.atomic.AtomicLong; public class SnowflakeGenerator { - // Epoch start (choose your custom epoch to reduce bits wasted on old time) - private static final long EPOCH = 1700000000000L; // ~2023-11-15 + private static final long EPOCH = Instant.parse("2025-01-01T00:00:00Z").toEpochMilli(); // Bit allocations private static final long TIMESTAMP_BITS = 41; @@ -14,20 +16,41 @@ public class SnowflakeGenerator { // Max values private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1; private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; + private static final long MAX_TIMESTAMP = (1L << TIMESTAMP_BITS) - 1; // Bit shifts private static final long MACHINE_SHIFT = SEQUENCE_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + MACHINE_BITS; - private final long machineId; + private static final long machineId = SnowflakeGenerator.genMachineId(); private final AtomicLong lastTimestamp = new AtomicLong(-1L); private long sequence = 0L; - public SnowflakeGenerator(long machineId) { - if (machineId < 0 || machineId > MAX_MACHINE_ID) { - throw new IllegalArgumentException("Machine ID must be between 0 and " + MAX_MACHINE_ID); + private static long genMachineId() { + try { + StringBuilder sb = new StringBuilder(); + for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) { + byte[] mac = ni.getHardwareAddress(); + if (mac != null) { + for (byte b : mac) sb.append(String.format("%02X", b)); + } + } + // limit to 10 bits (0–1023) + return sb.toString().hashCode() & 0x3FF; + } catch (Exception e) { + return (long) (Math.random() * 1024); // fallback + } + } + + void setTime(long l) { + this.lastTimestamp.set(l); + } + + public SnowflakeGenerator() { + if (machineId < 0 || machineId > MAX_MACHINE_ID) { + throw new IllegalArgumentException( + "Machine ID must be between 0 and " + MAX_MACHINE_ID); } - this.machineId = machineId; } public synchronized long nextId() { @@ -37,6 +60,10 @@ public class SnowflakeGenerator { throw new IllegalStateException("Clock moved backwards. Refusing to generate id."); } + if (currentTimestamp > MAX_TIMESTAMP) { + throw new IllegalStateException("Timestamp bits overflow, Snowflake expired."); + } + if (currentTimestamp == lastTimestamp.get()) { sequence = (sequence + 1) & MAX_SEQUENCE; if (sequence == 0) { @@ -65,4 +92,4 @@ public class SnowflakeGenerator { private long timestamp() { return System.currentTimeMillis(); } -} \ No newline at end of file +} diff --git a/framework/src/main/java/org/toop/framework/asset/Asset.java b/framework/src/main/java/org/toop/framework/asset/Asset.java new file mode 100644 index 0000000..9f1f488 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/Asset.java @@ -0,0 +1,29 @@ +package org.toop.framework.asset; + +import org.toop.framework.SnowflakeGenerator; +import org.toop.framework.asset.resources.BaseResource; + +public class Asset { + private final Long id; + private final String name; + private final T resource; + + public Asset(String name, T resource) { + this.id = new SnowflakeGenerator().nextId(); + this.name = name; + this.resource = resource; + } + + public Long getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public T getResource() { + return this.resource; + } + +} diff --git a/framework/src/main/java/org/toop/framework/asset/AssetLoader.java b/framework/src/main/java/org/toop/framework/asset/AssetLoader.java new file mode 100644 index 0000000..6d632fc --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/AssetLoader.java @@ -0,0 +1,132 @@ +package org.toop.framework.asset; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.toop.framework.asset.events.AssetEvents; +import org.toop.framework.asset.resources.BaseResource; + +import java.io.File; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +import org.reflections.Reflections; +import org.toop.framework.asset.resources.FileExtension; +import org.toop.framework.asset.resources.FontAsset; +import org.toop.framework.eventbus.EventFlow; + +public class AssetLoader { + private static final Logger logger = LogManager.getLogger(AssetLoader.class); + private final List> assets = new CopyOnWriteArrayList<>(); + private final Map> registry = new ConcurrentHashMap<>(); + + private volatile int loadedCount = 0; + private volatile int totalCount = 0; + + public AssetLoader(File rootFolder) { + autoRegisterResources(); // make sure resources are registered! + + List foundFiles = new ArrayList<>(); + fileSearcher(rootFolder, foundFiles); + this.totalCount = foundFiles.size(); + loader(foundFiles); + } + + public AssetLoader(String rootFolder) { + this(new File(rootFolder)); + } + + public double getProgress() { + return (this.totalCount == 0) ? 1.0 : (this.loadedCount / (double) this.totalCount); + } + + public int getLoadedCount() { + return this.loadedCount; + } + + public int getTotalCount() { + return this.totalCount; + } + + public List> getAssets() { + return new ArrayList<>(this.assets); + } + + public void register(String extension, Function factory) { + this.registry.put(extension, factory); + } + + private T resourceMapper(File file, Class type) { + String ext = getExtension(file.getName()); + Function factory = this.registry.get(ext); + + if (factory == null) return null; + + BaseResource resource = factory.apply(file); + + if (!type.isInstance(resource)) { + throw new IllegalArgumentException( + "File " + file.getName() + " is not of type " + type.getSimpleName() + ); + } + + return type.cast(resource); + } + + private void loader(List files) { + for (File file : files) { + BaseResource resource = resourceMapper(file, BaseResource.class); + if (resource != null) { + Asset asset = new Asset<>(file.getName(), resource); + this.assets.add(asset); + + if (resource instanceof FontAsset fontAsset) { + fontAsset.load(); + } + + logger.info("Loaded {} from {}", resource.getClass().getSimpleName(), file.getAbsolutePath()); + + this.loadedCount++; // TODO: Fix non atmomic operation + new EventFlow() + .addPostEvent(new AssetEvents.LoadingProgressUpdate(this.loadedCount, this.totalCount)) + .postEvent(); + } + } + logger.info("Loaded {} assets", files.size()); + } + + private void fileSearcher(final File folder, List foundFiles) { + for (File fileEntry : Objects.requireNonNull(folder.listFiles())) { + if (fileEntry.isDirectory()) { + fileSearcher(fileEntry, foundFiles); + } else { + foundFiles.add(fileEntry); + } + } + } + + private void autoRegisterResources() { + Reflections reflections = new Reflections("org.toop.framework.asset.resources"); + Set> classes = reflections.getSubTypesOf(BaseResource.class); + + for (Class cls : classes) { + if (!cls.isAnnotationPresent(FileExtension.class)) continue; + FileExtension annotation = cls.getAnnotation(FileExtension.class); + for (String ext : annotation.value()) { + this.registry.put(ext, file -> { + try { + return cls.getConstructor(File.class).newInstance(file); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + } + } + + public static String getExtension(String name) { + int i = name.lastIndexOf('.'); + return (i > 0) ? name.substring(i + 1) : ""; + } +} diff --git a/framework/src/main/java/org/toop/framework/asset/AssetManager.java b/framework/src/main/java/org/toop/framework/asset/AssetManager.java new file mode 100644 index 0000000..e53aac0 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/AssetManager.java @@ -0,0 +1,57 @@ +package org.toop.framework.asset; + +import org.toop.framework.asset.resources.*; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class AssetManager { + private static final AssetManager INSTANCE = new AssetManager(); + private static final Map> assets = new ConcurrentHashMap<>(); + + private AssetManager() {} + + public static AssetManager getInstance() { + return INSTANCE; + } + + public synchronized static void loadAssets(AssetLoader loader) { + for (var asset : loader.getAssets()) { + assets.put(asset.getName(), asset); + } + } + + public static ArrayList> getAllOfType(Class type) { + ArrayList> list = new ArrayList<>(); + for (Asset asset : assets.values()) { // <-- use .values() + if (type.isInstance(asset.getResource())) { + @SuppressWarnings("unchecked") + Asset typed = (Asset) asset; + list.add(typed); + } + } + return list; + } + + public static Asset getById(String id) { + for (Asset asset : assets.values()) { + if (asset.getId().toString().equals(id)) { + return asset; + } + } + return null; + } + + public static Asset getByName(String name) { + return assets.get(name); + } + + public static Optional> findByName(String name) { + return Optional.ofNullable(assets.get(name)); + } + + public static void addAsset(Asset asset) { + assets.put(asset.getName(), asset); + } + +} \ No newline at end of file diff --git a/framework/src/main/java/org/toop/framework/asset/events/AssetEvents.java b/framework/src/main/java/org/toop/framework/asset/events/AssetEvents.java new file mode 100644 index 0000000..faf8bfb --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/events/AssetEvents.java @@ -0,0 +1,7 @@ +package org.toop.framework.asset.events; + +import org.toop.framework.eventbus.events.EventWithoutSnowflake; + +public class AssetEvents { + public record LoadingProgressUpdate(int hasLoadedAmount, int isLoadingAmount) implements EventWithoutSnowflake {} +} diff --git a/framework/src/main/java/org/toop/framework/asset/resources/AudioAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/AudioAsset.java new file mode 100644 index 0000000..4b00212 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/AudioAsset.java @@ -0,0 +1,51 @@ +package org.toop.framework.asset.resources; + +import javax.sound.sampled.*; +import java.io.*; + +@FileExtension({"wav"}) +public class AudioAsset extends BaseResource implements LoadableResource { + + public AudioAsset(final File audioFile) { + super(audioFile); + } + + // Gets a new clip to play + public Clip getNewClip() throws LineUnavailableException, UnsupportedAudioFileException, IOException { + if(!this.isLoaded()){ + this.load(); + } + + // Get a new clip from audio system + Clip clip = AudioSystem.getClip(); + + // Insert a new audio stream into the clip + clip.open(this.getAudioStream()); + return clip; + } + + // Generates a new audio stream from byte array + private AudioInputStream getAudioStream() throws UnsupportedAudioFileException, IOException { + return AudioSystem.getAudioInputStream(this.file); + } + + @Override + public void load() { + try { + this.getAudioStream(); + this.isLoaded = true; + } catch (UnsupportedAudioFileException | IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void unload() { + this.isLoaded = false; // TODO? + } + + @Override + public boolean isLoaded() { + return this.isLoaded; + } +} diff --git a/framework/src/main/java/org/toop/framework/asset/resources/BaseResource.java b/framework/src/main/java/org/toop/framework/asset/resources/BaseResource.java new file mode 100644 index 0000000..c1aa040 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/BaseResource.java @@ -0,0 +1,18 @@ +package org.toop.framework.asset.resources; + +import java.io.*; + +public abstract class BaseResource { + + final File file; + boolean isLoaded = false; + + protected BaseResource(final File file) { + this.file = file; + } + + public File getFile() { + return this.file; + } + +} diff --git a/framework/src/main/java/org/toop/framework/asset/resources/CssAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/CssAsset.java new file mode 100644 index 0000000..367fe80 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/CssAsset.java @@ -0,0 +1,17 @@ +package org.toop.framework.asset.resources; + +import java.io.File; + +@FileExtension({"css"}) +public class CssAsset extends BaseResource { + private final String url; + + public CssAsset(File file) { + super(file); + this.url = file.toURI().toString(); + } + + public String getUrl() { + return url; + } +} diff --git a/framework/src/main/java/org/toop/framework/asset/resources/FileExtension.java b/framework/src/main/java/org/toop/framework/asset/resources/FileExtension.java new file mode 100644 index 0000000..4aceb00 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/FileExtension.java @@ -0,0 +1,12 @@ +package org.toop.framework.asset.resources; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface FileExtension { + String[] value(); +} \ No newline at end of file diff --git a/framework/src/main/java/org/toop/framework/asset/resources/FontAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/FontAsset.java new file mode 100644 index 0000000..16570de --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/FontAsset.java @@ -0,0 +1,60 @@ +package org.toop.framework.asset.resources; + +import javafx.scene.text.Font; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +@FileExtension({"ttf", "otf"}) +public class FontAsset extends BaseResource implements LoadableResource { + private String family; + + public FontAsset(final File fontFile) { + super(fontFile); + } + + @Override + public void load() { + if (!this.isLoaded) { + try (FileInputStream fis = new FileInputStream(this.file)) { + // Register font with JavaFX + Font font = Font.loadFont(fis, 12); // Default preview size + if (font == null) { + throw new RuntimeException("Failed to load font: " + this.file); + } + this.family = font.getFamily(); // Save family name for CSS / future use + this.isLoaded = true; + } catch (IOException e) { + throw new RuntimeException("Error reading font file: " + this.file, e); + } + } + } + + @Override + public void unload() { + // Font remains globally registered with JavaFX, but we just forget it locally + this.family = null; + this.isLoaded = false; + } + + @Override + public boolean isLoaded() { + return this.isLoaded; + } + + /** Get a new font instance with the given size */ + public Font getFont(double size) { + if (!this.isLoaded) { + load(); + } + return Font.font(this.family, size); + } + + /** Get the family name (for CSS usage) */ + public String getFamily() { + if (!this.isLoaded) { + load(); + } + return this.family; + } +} \ No newline at end of file diff --git a/framework/src/main/java/org/toop/framework/asset/resources/ImageAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/ImageAsset.java new file mode 100644 index 0000000..3cc0a3b --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/ImageAsset.java @@ -0,0 +1,45 @@ +package org.toop.framework.asset.resources; + +import javafx.scene.image.Image; +import java.io.File; +import java.io.FileInputStream; + +@FileExtension({"png", "jpg", "jpeg"}) +public class ImageAsset extends BaseResource implements LoadableResource { + private Image image; + + public ImageAsset(final File file) { + super(file); + } + + @Override + public void load() { + if (!this.isLoaded) { + try (FileInputStream fis = new FileInputStream(this.file)) { + this.image = new Image(fis); + this.isLoaded = true; + } catch (Exception e) { + throw new RuntimeException("Failed to load image: " + this.file, e); + } + } + } + + @Override + public void unload() { + this.image = null; + this.isLoaded = false; + } + + @Override + public boolean isLoaded() { + return this.isLoaded; + } + + public Image getImage() { + if (!this.isLoaded) { + this.load(); + return image; + } + return null; + } +} \ No newline at end of file diff --git a/framework/src/main/java/org/toop/framework/asset/resources/LoadableResource.java b/framework/src/main/java/org/toop/framework/asset/resources/LoadableResource.java new file mode 100644 index 0000000..4b94092 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/LoadableResource.java @@ -0,0 +1,9 @@ +package org.toop.framework.asset.resources; + +import java.io.FileNotFoundException; + +public interface LoadableResource { + void load(); + void unload(); + boolean isLoaded(); +} diff --git a/framework/src/main/java/org/toop/framework/asset/resources/LocalizationAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/LocalizationAsset.java new file mode 100644 index 0000000..27d9b83 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/LocalizationAsset.java @@ -0,0 +1,85 @@ +package org.toop.framework.asset.resources; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +@FileExtension({"properties"}) +public class LocalizationAsset extends BaseResource implements LoadableResource { + private final Map bundles = new HashMap<>(); + private boolean isLoaded = false; + + public LocalizationAsset(File file) { + super(file); + } + + @Override + public void load() { + // Convention: file names like messages_en.properties, ui_de.properties, etc. + String baseName = getBaseName(getFile().getName()); + + // Scan the parent folder for all matching *.properties with same basename + File folder = getFile().getParentFile(); + File[] files = folder.listFiles((dir, name) -> + name.startsWith(baseName) && name.endsWith(".properties")); + + if (files != null) { + for (File f : files) { + Locale locale = extractLocale(f.getName(), baseName); + try (InputStreamReader reader = + new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)) { + this.bundles.put(locale, new PropertyResourceBundle(reader)); + } catch (IOException e) { + throw new RuntimeException("Failed to load localization file: " + f, e); + } + } + } + + this.isLoaded = true; + } + + @Override + public void unload() { + this.bundles.clear(); + this.isLoaded = false; + } + + @Override + public boolean isLoaded() { + return this.isLoaded; + } + + public String getString(String key, Locale locale) { + ResourceBundle bundle = this.bundles.get(locale); + if (bundle == null) throw new MissingResourceException( + "No bundle for locale: " + locale, getClass().getName(), key); + return bundle.getString(key); + } + + public boolean hasLocale(Locale locale) { + return this.bundles.containsKey(locale); + } + + public Set getAvailableLocales() { + return Collections.unmodifiableSet(this.bundles.keySet()); + } + + private String getBaseName(String fileName) { + int underscoreIndex = fileName.indexOf('_'); + int dotIndex = fileName.lastIndexOf('.'); + if (underscoreIndex > 0) { + return fileName.substring(0, underscoreIndex); + } + return fileName.substring(0, dotIndex); + } + + private Locale extractLocale(String fileName, String baseName) { + int underscoreIndex = fileName.indexOf('_'); + int dotIndex = fileName.lastIndexOf('.'); + if (underscoreIndex > 0 && dotIndex > underscoreIndex) { + String localePart = fileName.substring(underscoreIndex + 1, dotIndex); + return Locale.forLanguageTag(localePart.replace('_', '-')); + } + return Locale.getDefault(); // fallback + } +} \ No newline at end of file diff --git a/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java new file mode 100644 index 0000000..8fc51bc --- /dev/null +++ b/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java @@ -0,0 +1,41 @@ +package org.toop.framework.asset.resources; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +@FileExtension({"txt", "json", "xml"}) +public class TextAsset extends BaseResource implements LoadableResource { + private String content; + + public TextAsset(File file) { + super(file); + } + + @Override + public void load() { + try { + byte[] bytes = Files.readAllBytes(getFile().toPath()); + this.content = new String(bytes, StandardCharsets.UTF_8); + this.isLoaded = true; + } catch (IOException e) { + throw new RuntimeException("Failed to load text asset: " + getFile(), e); + } + } + + @Override + public void unload() { + this.content = null; + this.isLoaded = false; + } + + @Override + public boolean isLoaded() { + return this.isLoaded; + } + + public String getContent() { + return this.content; + } +} \ No newline at end of file diff --git a/framework/src/main/java/org/toop/framework/audio/SoundManager.java b/framework/src/main/java/org/toop/framework/audio/SoundManager.java new file mode 100644 index 0000000..2727358 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/audio/SoundManager.java @@ -0,0 +1,107 @@ +package org.toop.framework.audio; + +import org.toop.framework.SnowflakeGenerator; +import org.toop.framework.asset.Asset; +import org.toop.framework.asset.AssetManager; +import org.toop.framework.asset.resources.AudioAsset; +import org.toop.framework.audio.events.AudioEvents; +import org.toop.framework.eventbus.EventFlow; + +import java.io.*; +import java.util.*; +import javax.sound.sampled.*; + +public class SoundManager { + private final Map activeClips = new HashMap<>(); + private final HashMap audioResources = new HashMap<>(); + private final SnowflakeGenerator idGenerator = new SnowflakeGenerator(); // TODO: Don't create a new generator + + public SoundManager() { + // Get all Audio Resources and add them to a list. + for (Asset asset : AssetManager.getAllOfType(AudioAsset.class)) { + try { + this.addAudioResource(asset); + } catch (IOException | LineUnavailableException | UnsupportedAudioFileException e) { + throw new RuntimeException(e); + } + } + new EventFlow() + .listen(this::handlePlaySound) + .listen(this::handleStopSound); + } + + private void handlePlaySound(AudioEvents.PlayAudio event) { + try { + this.playSound(event.fileNameNoExtensionAndNoDirectory(), event.loop()); + } catch (UnsupportedAudioFileException | LineUnavailableException | IOException e) { + throw new RuntimeException(e); + } + } + + private void handleStopSound(AudioEvents.StopAudio event) { + this.stopSound(event.clipId()); + } + + private void addAudioResource(Asset audioAsset) + throws IOException, UnsupportedAudioFileException, LineUnavailableException { + + this.audioResources.put(audioAsset.getName(), audioAsset.getResource()); + } + + private long playSound(String audioFileName, boolean loop) throws UnsupportedAudioFileException, LineUnavailableException, IOException { + AudioAsset asset = audioResources.get(audioFileName); + + // Return -1 which indicates resource wasn't available + if (asset == null){ + return -1; + } + + // Get a new clip from resource + Clip clip = asset.getNewClip(); + + // If supposed to loop make it loop, else just start it once + if (loop) { + clip.loop(Clip.LOOP_CONTINUOUSLY); + } + else { + clip.start(); + } + + // Generate id for clip + long clipId = idGenerator.nextId(); + + // store it so we can stop it later + activeClips.put(clipId, clip); // TODO: Do on snowflake for specific sound to stop + + // remove when finished (only for non-looping sounds) + clip.addLineListener(event -> { + if (event.getType() == LineEvent.Type.STOP && !clip.isRunning()) { + activeClips.remove(clipId); + clip.close(); + } + }); + + // Return id so it can be stopped + return clipId; + } + + public void stopSound(long clipId) { + Clip clip = activeClips.get(clipId); + + if (clip == null) { + return; + } + + clip.stop(); + clip.close(); + activeClips.remove(clipId); + } + + public void stopAllSounds() { + for (Clip clip : activeClips.values()) { + clip.stop(); + clip.close(); + } + activeClips.clear(); + } +} diff --git a/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java b/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java new file mode 100644 index 0000000..74bc41a --- /dev/null +++ b/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java @@ -0,0 +1,12 @@ +package org.toop.framework.audio.events; + +import org.toop.framework.eventbus.events.EventWithoutSnowflake; +import org.toop.framework.eventbus.events.EventsBase; + +public class AudioEvents extends EventsBase { + /** Starts playing a sound. */ + public record PlayAudio(String fileNameNoExtensionAndNoDirectory, boolean loop) + implements EventWithoutSnowflake {} + + public record StopAudio(long clipId) implements EventWithoutSnowflake {} +} diff --git a/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java b/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java index 5d71f61..4c4a8de 100644 --- a/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java +++ b/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java @@ -1,24 +1,26 @@ package org.toop.framework.eventbus; -import org.toop.framework.eventbus.events.EventType; -import org.toop.framework.eventbus.events.EventWithSnowflake; -import org.toop.framework.eventbus.SnowflakeGenerator; - import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import java.util.function.Supplier; +import org.toop.framework.SnowflakeGenerator; +import org.toop.framework.eventbus.events.EventType; +import org.toop.framework.eventbus.events.EventWithSnowflake; /** - * EventFlow is a utility class for creating, posting, and optionally subscribing to events - * in a type-safe and chainable manner. It is designed to work with the {@link GlobalEventBus}. + * EventFlow is a utility class for creating, posting, and optionally subscribing to events in a + * type-safe and chainable manner. It is designed to work with the {@link GlobalEventBus}. * - *

This class supports automatic UUID assignment for {@link EventWithSnowflake} events, - * and allows filtering subscribers so they only respond to events with a specific UUID. - * All subscription methods are chainable, and you can configure automatic unsubscription - * after an event has been successfully handled.

+ *

This class supports automatic UUID assignment for {@link EventWithSnowflake} events, and + * allows filtering subscribers so they only respond to events with a specific UUID. All + * subscription methods are chainable, and you can configure automatic unsubscription after an event + * has been successfully handled. */ public class EventFlow { @@ -35,10 +37,7 @@ public class EventFlow { private EventType event = null; /** The listener returned by GlobalEventBus subscription. Used for unsubscription. */ - private Object listener; - - /** Flag indicating whether to automatically unsubscribe the listener after success. */ - private boolean unsubscribeAfterSuccess = false; + private final List listeners = new ArrayList<>(); /** Holds the results returned from the subscribed event, if any. */ private Map result = null; @@ -46,28 +45,43 @@ public class EventFlow { /** Empty constructor (event must be added via {@link #addPostEvent(Class, Object...)}). */ public EventFlow() {} - /** - * Instantiate an event of the given class and store it in this publisher. - */ + // New: accept an event instance directly + public EventFlow addPostEvent(EventType event) { + this.event = event; + return this; + } + + // Optional: accept a Supplier to defer construction + public EventFlow addPostEvent(Supplier eventSupplier) { + this.event = eventSupplier.get(); + return this; + } + + // Keep the old class+args version if needed public EventFlow addPostEvent(Class eventClass, Object... args) { try { boolean isUuidEvent = EventWithSnowflake.class.isAssignableFrom(eventClass); - MethodHandle ctorHandle = CONSTRUCTOR_CACHE.computeIfAbsent(eventClass, cls -> { - try { - Class[] paramTypes = cls.getDeclaredConstructors()[0].getParameterTypes(); - MethodType mt = MethodType.methodType(void.class, paramTypes); - return LOOKUP.findConstructor(cls, mt); - } catch (Exception e) { - throw new RuntimeException("Failed to find constructor handle for " + cls, e); - } - }); + MethodHandle ctorHandle = + CONSTRUCTOR_CACHE.computeIfAbsent( + eventClass, + cls -> { + try { + Class[] paramTypes = + cls.getDeclaredConstructors()[0].getParameterTypes(); + MethodType mt = MethodType.methodType(void.class, paramTypes); + return LOOKUP.findConstructor(cls, mt); + } catch (Exception e) { + throw new RuntimeException( + "Failed to find constructor handle for " + cls, e); + } + }); Object[] finalArgs; int expectedParamCount = ctorHandle.type().parameterCount(); if (isUuidEvent && args.length < expectedParamCount) { - this.eventSnowflake = new SnowflakeGenerator(1).nextId(); + this.eventSnowflake = new SnowflakeGenerator().nextId(); finalArgs = new Object[args.length + 1]; System.arraycopy(args, 0, finalArgs, 0, args.length); finalArgs[args.length] = this.eventSnowflake; @@ -86,124 +100,140 @@ public class EventFlow { } } - /** - * Start listening for a response event type, chainable with perform(). - */ - public ResponseBuilder onResponse(Class eventClass) { - return new ResponseBuilder<>(this, eventClass); - } + // public EventFlow addSnowflake() { + // this.eventSnowflake = new SnowflakeGenerator(1).nextId(); + // return this; + // } - public static class ResponseBuilder { - private final EventFlow parent; - private final Class responseClass; + /** Subscribe by ID: only fires if UUID matches this publisher's eventId. */ + public EventFlow onResponse( + Class eventClass, Consumer action, boolean unsubscribeAfterSuccess) { + ListenerHandler[] listenerHolder = new ListenerHandler[1]; + listenerHolder[0] = + new ListenerHandler( + GlobalEventBus.subscribe( + eventClass, + event -> { + if (event.eventSnowflake() != this.eventSnowflake) return; - ResponseBuilder(EventFlow parent, Class responseClass) { - this.parent = parent; - this.responseClass = responseClass; - } + action.accept(event); - /** Finalize the subscription */ - public EventFlow perform(Consumer action) { - parent.listener = GlobalEventBus.subscribe(responseClass, event -> { - action.accept(responseClass.cast(event)); - if (parent.unsubscribeAfterSuccess && parent.listener != null) { - GlobalEventBus.unsubscribe(parent.listener); - } - }); - return parent; - } - } + if (unsubscribeAfterSuccess && listenerHolder[0] != null) { + GlobalEventBus.unsubscribe(listenerHolder[0]); + this.listeners.remove(listenerHolder[0]); + } - /** - * Subscribe by ID: only fires if UUID matches this publisher's eventId. - */ - public EventFlow onResponse(Class eventClass, Consumer action) { - this.listener = GlobalEventBus.subscribe(eventClass, event -> { - if (event.eventSnowflake() == this.eventSnowflake) { - action.accept(event); - if (unsubscribeAfterSuccess && listener != null) { - GlobalEventBus.unsubscribe(listener); - } - this.result = event.result(); - } - }); + this.result = event.result(); + })); + this.listeners.add(listenerHolder[0]); return this; } - /** - * Subscribe by ID without explicit class. - */ + /** Subscribe by ID: only fires if UUID matches this publisher's eventId. */ + public EventFlow onResponse( + Class eventClass, Consumer action) { + return this.onResponse(eventClass, action, true); + } + + /** Subscribe by ID without explicit class. */ @SuppressWarnings("unchecked") - public EventFlow onResponse(Consumer action) { - this.listener = GlobalEventBus.subscribe(event -> { - if (event instanceof EventWithSnowflake uuidEvent) { - if (uuidEvent.eventSnowflake() == this.eventSnowflake) { - try { - TT typedEvent = (TT) uuidEvent; - action.accept(typedEvent); - if (unsubscribeAfterSuccess && listener != null) { - GlobalEventBus.unsubscribe(listener); - } - this.result = typedEvent.result(); - } catch (ClassCastException ignored) {} - } - } - }); + public EventFlow onResponse( + Consumer action, boolean unsubscribeAfterSuccess) { + ListenerHandler[] listenerHolder = new ListenerHandler[1]; + listenerHolder[0] = + new ListenerHandler( + GlobalEventBus.subscribe( + event -> { + if (!(event instanceof EventWithSnowflake uuidEvent)) return; + if (uuidEvent.eventSnowflake() == this.eventSnowflake) { + try { + TT typedEvent = (TT) uuidEvent; + action.accept(typedEvent); + if (unsubscribeAfterSuccess + && listenerHolder[0] != null) { + GlobalEventBus.unsubscribe(listenerHolder[0]); + this.listeners.remove(listenerHolder[0]); + } + this.result = typedEvent.result(); + } catch (ClassCastException _) { + throw new ClassCastException( + "Cannot cast " + + event.getClass().getName() + + " to EventWithSnowflake"); + } + } + })); + this.listeners.add(listenerHolder[0]); return this; } - // choose event type - public EventSubscriberBuilder onEvent(Class eventClass) { - return new EventSubscriberBuilder<>(this, eventClass); + public EventFlow onResponse(Consumer action) { + return this.onResponse(action, true); + } + + public EventFlow listen( + Class eventClass, Consumer action, boolean unsubscribeAfterSuccess) { + ListenerHandler[] listenerHolder = new ListenerHandler[1]; + listenerHolder[0] = + new ListenerHandler( + GlobalEventBus.subscribe( + eventClass, + event -> { + action.accept(event); + + if (unsubscribeAfterSuccess && listenerHolder[0] != null) { + GlobalEventBus.unsubscribe(listenerHolder[0]); + this.listeners.remove(listenerHolder[0]); + } + })); + this.listeners.add(listenerHolder[0]); + return this; } - // One-liner shorthand public EventFlow listen(Class eventClass, Consumer action) { - return this.onEvent(eventClass).perform(action); + return this.listen(eventClass, action, true); } - // Builder for chaining .onEvent(...).perform(...) - public static class EventSubscriberBuilder { - private final EventFlow publisher; - private final Class eventClass; + @SuppressWarnings("unchecked") + public EventFlow listen( + Consumer action, boolean unsubscribeAfterSuccess) { + ListenerHandler[] listenerHolder = new ListenerHandler[1]; + listenerHolder[0] = + new ListenerHandler( + GlobalEventBus.subscribe( + event -> { + if (!(event instanceof EventType nonUuidEvent)) return; + try { + TT typedEvent = (TT) nonUuidEvent; + action.accept(typedEvent); + if (unsubscribeAfterSuccess && listenerHolder[0] != null) { + GlobalEventBus.unsubscribe(listenerHolder[0]); + this.listeners.remove(listenerHolder[0]); + } + } catch (ClassCastException _) { + throw new ClassCastException( + "Cannot cast " + + event.getClass().getName() + + " to EventWithSnowflake"); + } + })); + this.listeners.add(listenerHolder[0]); + return this; + } - EventSubscriberBuilder(EventFlow publisher, Class eventClass) { - this.publisher = publisher; - this.eventClass = eventClass; - } - - public EventFlow perform(Consumer action) { - publisher.listener = GlobalEventBus.subscribe(eventClass, event -> { - action.accept(eventClass.cast(event)); - if (publisher.unsubscribeAfterSuccess && publisher.listener != null) { - GlobalEventBus.unsubscribe(publisher.listener); - } - }); - return publisher; - } + public EventFlow listen(Consumer action) { + return this.listen(action, true); } /** Post synchronously */ public EventFlow postEvent() { - GlobalEventBus.post(event); + GlobalEventBus.post(this.event); return this; } /** Post asynchronously */ public EventFlow asyncPostEvent() { - GlobalEventBus.postAsync(event); - return this; - } - - public EventFlow unsubscribeAfterSuccess() { - this.unsubscribeAfterSuccess = true; - return this; - } - - public EventFlow unsubscribeNow() { - if (unsubscribeAfterSuccess && listener != null) { - GlobalEventBus.unsubscribe(listener); - } + GlobalEventBus.postAsync(this.event); return this; } @@ -215,7 +245,11 @@ public class EventFlow { return event; } - public long getEventId() { + public ListenerHandler[] getListeners() { + return listeners.toArray(new ListenerHandler[0]); + } + + public long getEventSnowflake() { return eventSnowflake; } } diff --git a/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java b/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java index 44a84f4..41386bf 100644 --- a/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java +++ b/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java @@ -3,26 +3,26 @@ package org.toop.framework.eventbus; import com.lmax.disruptor.*; import com.lmax.disruptor.dsl.Disruptor; import com.lmax.disruptor.dsl.ProducerType; -import org.toop.framework.eventbus.events.EventType; -import org.toop.framework.eventbus.events.EventWithSnowflake; - import java.util.Map; import java.util.concurrent.*; import java.util.function.Consumer; +import org.toop.framework.eventbus.events.EventType; +import org.toop.framework.eventbus.events.EventWithSnowflake; /** - * GlobalEventBus backed by the LMAX Disruptor for ultra-low latency, - * high-throughput event publishing. + * GlobalEventBus backed by the LMAX Disruptor for ultra-low latency, high-throughput event + * publishing. */ public final class GlobalEventBus { /** Map of event class to type-specific listeners. */ - private static final Map, CopyOnWriteArrayList>> LISTENERS = - new ConcurrentHashMap<>(); + private static final Map, CopyOnWriteArrayList>> + LISTENERS = new ConcurrentHashMap<>(); /** Map of event class to Snowflake-ID-specific listeners. */ - private static final Map, ConcurrentHashMap>> UUID_LISTENERS = - new ConcurrentHashMap<>(); + private static final Map< + Class, ConcurrentHashMap>> + UUID_LISTENERS = new ConcurrentHashMap<>(); /** Disruptor ring buffer size (must be power of two). */ private static final int RING_BUFFER_SIZE = 1024 * 64; @@ -34,27 +34,29 @@ public final class GlobalEventBus { private static final RingBuffer RING_BUFFER; static { - ThreadFactory threadFactory = r -> { - Thread t = new Thread(r, "EventBus-Disruptor"); - t.setDaemon(true); - return t; - }; + ThreadFactory threadFactory = + r -> { + Thread t = new Thread(r, "EventBus-Disruptor"); + t.setDaemon(true); + return t; + }; - DISRUPTOR = new Disruptor<>( - EventHolder::new, - RING_BUFFER_SIZE, - threadFactory, - ProducerType.MULTI, - new BusySpinWaitStrategy() - ); + DISRUPTOR = + new Disruptor<>( + EventHolder::new, + RING_BUFFER_SIZE, + threadFactory, + ProducerType.MULTI, + new BusySpinWaitStrategy()); // Single consumer that dispatches to subscribers - DISRUPTOR.handleEventsWith((holder, seq, endOfBatch) -> { - if (holder.event != null) { - dispatchEvent(holder.event); - holder.event = null; - } - }); + DISRUPTOR.handleEventsWith( + (holder, seq, endOfBatch) -> { + if (holder.event != null) { + dispatchEvent(holder.event); + holder.event = null; + } + }); DISRUPTOR.start(); RING_BUFFER = DISRUPTOR.getRingBuffer(); @@ -71,17 +73,21 @@ public final class GlobalEventBus { // ------------------------------------------------------------------------ // Subscription // ------------------------------------------------------------------------ - public static Consumer subscribe(Class eventClass, Consumer listener) { + public static Consumer subscribe( + Class eventClass, Consumer listener) { + CopyOnWriteArrayList> list = LISTENERS.computeIfAbsent(eventClass, k -> new CopyOnWriteArrayList<>()); - list.add(event -> listener.accept(eventClass.cast(event))); - return listener; + + Consumer wrapper = event -> listener.accept(eventClass.cast(event)); + list.add(wrapper); + return wrapper; } - public static Consumer subscribe(Consumer listener) { - LISTENERS.computeIfAbsent(Object.class, _ -> new CopyOnWriteArrayList<>()) - .add(listener); - return listener; + public static Consumer subscribe(Consumer listener) { + Consumer wrapper = event -> listener.accept(event); + LISTENERS.computeIfAbsent(Object.class, _ -> new CopyOnWriteArrayList<>()).add(wrapper); + return wrapper; } public static void subscribeById( @@ -95,7 +101,8 @@ public final class GlobalEventBus { LISTENERS.values().forEach(list -> list.remove(listener)); } - public static void unsubscribeById(Class eventClass, long eventId) { + public static void unsubscribeById( + Class eventClass, long eventId) { Map> map = UUID_LISTENERS.get(eventClass); if (map != null) map.remove(eventId); } @@ -125,15 +132,22 @@ public final class GlobalEventBus { CopyOnWriteArrayList> classListeners = LISTENERS.get(clazz); if (classListeners != null) { for (Consumer listener : classListeners) { - try { listener.accept(event); } catch (Throwable ignored) {} + try { + listener.accept(event); + } catch (Throwable ignored) { + } } } // generic listeners - CopyOnWriteArrayList> genericListeners = LISTENERS.get(Object.class); + CopyOnWriteArrayList> genericListeners = + LISTENERS.get(Object.class); if (genericListeners != null) { for (Consumer listener : genericListeners) { - try { listener.accept(event); } catch (Throwable ignored) {} + try { + listener.accept(event); + } catch (Throwable ignored) { + } } } @@ -144,7 +158,10 @@ public final class GlobalEventBus { Consumer listener = (Consumer) map.remove(snowflakeEvent.eventSnowflake()); if (listener != null) { - try { listener.accept(snowflakeEvent); } catch (Throwable ignored) {} + try { + listener.accept(snowflakeEvent); + } catch (Throwable ignored) { + } } } } diff --git a/framework/src/main/java/org/toop/framework/eventbus/ListenerHandler.java b/framework/src/main/java/org/toop/framework/eventbus/ListenerHandler.java new file mode 100644 index 0000000..8daa274 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/eventbus/ListenerHandler.java @@ -0,0 +1,25 @@ +package org.toop.framework.eventbus; + +public class ListenerHandler { + private Object listener = null; + + // private boolean unsubscribeAfterSuccess = true; + + // public ListenerHandler(Object listener, boolean unsubAfterSuccess) { + // this.listener = listener; + // this.unsubscribeAfterSuccess = unsubAfterSuccess; + // } + + public ListenerHandler(Object listener) { + this.listener = listener; + } + + public Object getListener() { + return this.listener; + } + + // public boolean isUnsubscribeAfterSuccess() { + // return this.unsubscribeAfterSuccess; + // } + +} diff --git a/framework/src/main/java/org/toop/framework/networking/NetworkingClient.java b/framework/src/main/java/org/toop/framework/networking/NetworkingClient.java index 91143ec..74f8a9f 100644 --- a/framework/src/main/java/org/toop/framework/networking/NetworkingClient.java +++ b/framework/src/main/java/org/toop/framework/networking/NetworkingClient.java @@ -7,53 +7,72 @@ import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.LineBasedFrameDecoder; import io.netty.handler.codec.string.StringDecoder; +import io.netty.handler.codec.string.StringEncoder; +import io.netty.util.CharsetUtil; +import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; - -import java.util.function.Supplier; +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.networking.events.NetworkEvents; public class NetworkingClient { private static final Logger logger = LogManager.getLogger(NetworkingClient.class); - final Bootstrap bootstrap = new Bootstrap(); - final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); - - private String connectionUuid; + private long connectionId; + private String host; + private int port; private Channel channel; private NetworkingGameClientHandler handler; public NetworkingClient( - Supplier handlerFactory, + Supplier handlerFactory, String host, - int port) { + int port, + long connectionId) { + this.connectionId = connectionId; try { - this.bootstrap.group(this.workerGroup); - this.bootstrap.channel(NioSocketChannel.class); - this.bootstrap.option(ChannelOption.SO_KEEPALIVE, true); - this.bootstrap.handler(new ChannelInitializer() { - @Override - public void initChannel(SocketChannel ch) { - handler = handlerFactory.get(); + Bootstrap bootstrap = new Bootstrap(); + EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + bootstrap.group(workerGroup); + bootstrap.channel(NioSocketChannel.class); + bootstrap.option(ChannelOption.SO_KEEPALIVE, true); + bootstrap.handler( + new ChannelInitializer() { + @Override + public void initChannel(SocketChannel ch) { + handler = handlerFactory.get(); - ChannelPipeline pipeline = ch.pipeline(); - pipeline.addLast(new LineBasedFrameDecoder(1024)); // split at \n - pipeline.addLast(new StringDecoder()); // bytes -> String - pipeline.addLast(handler); - } - }); - ChannelFuture channelFuture = this.bootstrap.connect(host, port).sync(); + ChannelPipeline pipeline = ch.pipeline(); + pipeline.addLast(new LineBasedFrameDecoder(1024)); // split at \n + pipeline.addLast( + new StringDecoder(CharsetUtil.UTF_8)); // bytes -> String + pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8)); + pipeline.addLast(handler); + } + }); + ChannelFuture channelFuture = bootstrap.connect(host, port).sync(); this.channel = channelFuture.channel(); + this.host = host; + this.port = port; } catch (Exception e) { logger.error("Failed to create networking client instance", e); } } public NetworkingGameClientHandler getHandler() { - return handler; + return this.handler; } - public void setConnectionUuid(String connectionUuid) { - this.connectionUuid = connectionUuid; + public String getHost() { + return this.host; + } + + public int getPort() { + return this.port; + } + + public void setConnectionId(long connectionId) { + this.connectionId = connectionId; } public boolean isChannelActive() { @@ -64,77 +83,48 @@ public class NetworkingClient { String literalMsg = msg.replace("\n", "\\n").replace("\r", "\\r"); if (isChannelActive()) { this.channel.writeAndFlush(msg); - logger.info("Connection {} sent message: {}", this.channel.remoteAddress(), literalMsg); + logger.info( + "Connection {} sent message: '{}'", this.channel.remoteAddress(), literalMsg); } else { - logger.warn("Cannot send message: {}, connection inactive.", literalMsg); + logger.warn("Cannot send message: '{}', connection inactive.", literalMsg); } } public void writeAndFlushnl(String msg) { if (isChannelActive()) { - this.channel.writeAndFlush(msg + "\n"); - logger.info("Connection {} sent message: {}", this.channel.remoteAddress(), msg); + this.channel.writeAndFlush(msg + "\r\n"); + logger.info("Connection {} sent message: '{}'", this.channel.remoteAddress(), msg); } else { - logger.warn("Cannot send message: {}, connection inactive.", msg); + logger.warn("Cannot send message: '{}', connection inactive.", msg); } } - public void login(String username) { - this.writeAndFlush("login " + username + "\n"); - } - - public void logout() { - this.writeAndFlush("logout\n"); - } - - public void sendMove(int move) { - this.writeAndFlush("move " + move + "\n"); // append \n so server receives a full line - } - - public void getGamelist() { - this.writeAndFlush("get gamelist\n"); - } - - public void getPlayerlist() { - this.writeAndFlush("get playerlist\n"); - } - - public void subscribe(String gameType) { - this.writeAndFlush("subscribe " + gameType + "\n"); - } - - public void forfeit() { - this.writeAndFlush("forfeit\n"); - } - - public void challenge(String playerName, String gameType) { - this.writeAndFlush("challenge " + playerName + " " + gameType + "\n"); - } - - public void acceptChallenge(String challengeNumber) { - this.writeAndFlush("challenge accept " + challengeNumber + "\n"); - } - - public void sendChatMessage(String message) { - this.writeAndFlush("message " + "\"" + message + "\"" + "\n"); - } - - public void help(String command) { - this.writeAndFlush("help " + command + "\n"); - } - public void closeConnection() { if (this.channel != null && this.channel.isActive()) { - this.channel.close().addListener(future -> { - if (future.isSuccess()) { - logger.info("Connection {} closed successfully", this.channel.remoteAddress()); - } else { - logger.error("Error closing connection {}. Error: {}", - this.channel.remoteAddress(), - future.cause().getMessage()); - } - }); + this.channel + .close() + .addListener( + future -> { + if (future.isSuccess()) { + logger.info( + "Connection {} closed successfully", + this.channel.remoteAddress()); + new EventFlow() + .addPostEvent( + new NetworkEvents.ClosedConnection( + this.connectionId)) + .asyncPostEvent(); + } else { + logger.error( + "Error closing connection {}. Error: {}", + this.channel.remoteAddress(), + future.cause().getMessage()); + } + }); } } + public long getId() { + return this.connectionId; + } } diff --git a/framework/src/main/java/org/toop/framework/networking/NetworkingClientManager.java b/framework/src/main/java/org/toop/framework/networking/NetworkingClientManager.java index 346d5c8..9c313d1 100644 --- a/framework/src/main/java/org/toop/framework/networking/NetworkingClientManager.java +++ b/framework/src/main/java/org/toop/framework/networking/NetworkingClientManager.java @@ -2,10 +2,9 @@ package org.toop.framework.networking; import java.util.*; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.toop.framework.SnowflakeGenerator; import org.toop.framework.eventbus.EventFlow; import org.toop.framework.networking.events.NetworkEvents; @@ -14,111 +13,189 @@ public class NetworkingClientManager { private static final Logger logger = LogManager.getLogger(NetworkingClientManager.class); /** Map of serverId -> Server instances */ - private final Map networkClients = new ConcurrentHashMap<>(); + final Map networkClients = new ConcurrentHashMap<>(); /** Starts a connection manager, to manage, connections. */ public NetworkingClientManager() throws NetworkingInitializationException { try { - new EventFlow().listen(NetworkEvents.StartClientRequest.class, this::handleStartClientRequest); - new EventFlow().listen(NetworkEvents.StartClient.class, this::handleStartClient); - new EventFlow().listen(NetworkEvents.SendCommand.class, this::handleCommand); - new EventFlow().listen(NetworkEvents.CloseClient.class, this::handleCloseClient); - new EventFlow().listen(NetworkEvents.RequestsAllClients.class, this::getAllConnections); - new EventFlow().listen(NetworkEvents.ForceCloseAllClients.class, this::shutdownAll); - } catch (Exception e) { + new EventFlow() + .listen(this::handleStartClient) + .listen(this::handleCommand) + .listen(this::handleSendLogin) + .listen(this::handleSendLogout) + .listen(this::handleSendGetPlayerlist) + .listen(this::handleSendGetGamelist) + .listen(this::handleSendSubscribe) + .listen(this::handleSendMove) + .listen(this::handleSendChallenge) + .listen(this::handleSendAcceptChallenge) + .listen(this::handleSendForfeit) + .listen(this::handleSendMessage) + .listen(this::handleSendHelp) + .listen(this::handleSendHelpForCommand) + .listen(this::handleCloseClient) + .listen(this::handleChangeClientHost) + .listen(this::handleGetAllConnections) + .listen(this::handleShutdownAll); + logger.info("NetworkingClientManager initialized"); + } catch (Exception e) { logger.error("Failed to initialize the client manager", e); throw e; } } - private String startClientRequest(Supplier handlerFactory, - String ip, - int port) { - String connectionUuid = UUID.randomUUID().toString(); - try { - NetworkingClient client = new NetworkingClient( - handlerFactory, - ip, - port); - this.networkClients.put(connectionUuid, client); + long startClientRequest(String ip, int port) { + long connectionId = new SnowflakeGenerator().nextId(); // TODO: Maybe use the one generated + try { // With EventFlow + NetworkingClient client = + new NetworkingClient( + () -> new NetworkingGameClientHandler(connectionId), + ip, + port, + connectionId); + client.setConnectionId(connectionId); + this.networkClients.put(connectionId, client); + logger.info("New client started successfully for {}:{}", ip, port); } catch (Exception e) { logger.error(e); } - logger.info("Client {} started", connectionUuid); - return connectionUuid; + return connectionId; } - private void handleStartClientRequest(NetworkEvents.StartClientRequest request) { - request.future() - .complete( - this.startClientRequest( - request.handlerFactory(), - request.ip(), - request.port())); // TODO: Maybe post ConnectionEstablished event. + private long startClientRequest(String ip, int port, long clientId) { + try { // With EventFlow + NetworkingClient client = + new NetworkingClient( + () -> new NetworkingGameClientHandler(clientId), ip, port, clientId); + client.setConnectionId(clientId); + this.networkClients.replace(clientId, client); + logger.info( + "New client started successfully for {}:{}, replaced: {}", ip, port, clientId); + } catch (Exception e) { + logger.error(e); + } + logger.info("Client {} started", clientId); + return clientId; } - private void handleStartClient(NetworkEvents.StartClient event) { - String uuid = this.startClientRequest(event.handlerFactory(), event.ip(), event.port()); - new EventFlow().addPostEvent(NetworkEvents.StartClientSuccess.class, - uuid, event.eventSnowflake() - ).asyncPostEvent(); + void handleStartClient(NetworkEvents.StartClient event) { + long id = this.startClientRequest(event.ip(), event.port()); + new Thread( + () -> { + try { + Thread.sleep(100); // TODO: Is this a good idea? + new EventFlow() + .addPostEvent( + NetworkEvents.StartClientResponse.class, + id, + event.eventSnowflake()) + .asyncPostEvent(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }) + .start(); } - private void handleCommand( + void handleCommand( NetworkEvents.SendCommand event) { // TODO: Move this to ServerConnection class, keep it internal. - NetworkingClient client = this.networkClients.get(event.connectionId()); - logger.info("Preparing to send command: {} to server: {}", event.args(), client); - if (client != null) { - String args = String.join(" ", event.args()) + "\n"; - client.writeAndFlush(args); - } else { - logger.warn("Server {} not found for command '{}'", event.connectionId(), event.args()); - } + NetworkingClient client = this.networkClients.get(event.clientId()); + String args = String.join(" ", event.args()); + sendCommand(client, args); } - private void handleCloseClient(NetworkEvents.CloseClient event) { - NetworkingClient client = this.networkClients.get(event.connectionId()); + void handleSendLogin(NetworkEvents.SendLogin event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, String.format("LOGIN %s", event.username())); + } + + private void handleSendLogout(NetworkEvents.SendLogout event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, "LOGOUT"); + } + + private void handleSendGetPlayerlist(NetworkEvents.SendGetPlayerlist event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, "GET PLAYERLIST"); + } + + private void handleSendGetGamelist(NetworkEvents.SendGetGamelist event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, "GET GAMELIST"); + } + + private void handleSendSubscribe(NetworkEvents.SendSubscribe event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, String.format("SUBSCRIBE %s", event.gameType())); + } + + private void handleSendMove(NetworkEvents.SendMove event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, String.format("MOVE %d", event.moveNumber())); + } + + private void handleSendChallenge(NetworkEvents.SendChallenge event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand( + client, + String.format("CHALLENGE %s %s", event.usernameToChallenge(), event.gameType())); + } + + private void handleSendAcceptChallenge(NetworkEvents.SendAcceptChallenge event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, String.format("CHALLENGE ACCEPT %d", event.challengeId())); + } + + private void handleSendForfeit(NetworkEvents.SendForfeit event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, "FORFEIT"); + } + + private void handleSendMessage(NetworkEvents.SendMessage event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, String.format("MESSAGE %s", event.message())); + } + + private void handleSendHelp(NetworkEvents.SendHelp event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, "HELP"); + } + + private void handleSendHelpForCommand(NetworkEvents.SendHelpForCommand event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + sendCommand(client, String.format("HELP %s", event.command())); + } + + private void sendCommand(NetworkingClient client, String command) { + logger.info( + "Preparing to send command: {} to server: {}:{}. clientId: {}", + command.trim(), + client.getHost(), + client.getPort(), + client.getId()); + client.writeAndFlushnl(command); + } + + private void handleChangeClientHost(NetworkEvents.ChangeClientHost event) { + NetworkingClient client = this.networkClients.get(event.clientId()); + client.closeConnection(); + startClientRequest(event.ip(), event.port(), event.clientId()); + } + + void handleCloseClient(NetworkEvents.CloseClient event) { + NetworkingClient client = this.networkClients.get(event.clientId()); client.closeConnection(); // TODO: Check if not blocking, what if error, mb not remove? - this.networkClients.remove(event.connectionId()); - logger.info("Client {} closed successfully.", event.connectionId()); + this.networkClients.remove(event.clientId()); + logger.info("Client {} closed successfully.", event.clientId()); } -// private void handleReconnect(Events.ServerEvents.Reconnect event) { -// NetworkingClient client = this.networkClients.get(event.connectionId()); -// if (client != null) { -// try { -// client; -// logger.info("Server {} reconnected", event.connectionId()); -// } catch (Exception e) { -// logger.error("Server {} failed to reconnect", event.connectionId(), e); -// GlobalEventBus.post(new Events.ServerEvents.CouldNotConnect(event.connectionId())); -// } -// } -// } // TODO: Reconnect on disconnect - - // private void handleChangeConnection(Events.ServerEvents.ChangeConnection event) { - // ServerConnection serverConnection = this.serverConnections.get(event.connectionId()); - // if (serverConnection != null) { - // try { - // serverConnection.connect(event.ip(), event.port()); - // logger.info("Server {} changed connection to {}:{}", event.connectionId(), - // event.ip(), event.port()); - // } catch (Exception e) { - // logger.error("Server {} failed to change connection", event.connectionId(), - // e); - // GlobalEventBus.post(new - // Events.ServerEvents.CouldNotConnect(event.connectionId())); - // } - // } - // } TODO - - private void getAllConnections(NetworkEvents.RequestsAllClients request) { + void handleGetAllConnections(NetworkEvents.RequestsAllClients request) { List a = new ArrayList<>(this.networkClients.values()); - request.future().complete(a.toString()); + request.future().complete(a); } - public void shutdownAll(NetworkEvents.ForceCloseAllClients request) { + public void handleShutdownAll(NetworkEvents.ForceCloseAllClients request) { this.networkClients.values().forEach(NetworkingClient::closeConnection); this.networkClients.clear(); logger.info("All servers shut down"); diff --git a/framework/src/main/java/org/toop/framework/networking/NetworkingGameClientHandler.java b/framework/src/main/java/org/toop/framework/networking/NetworkingGameClientHandler.java index 0b7ca49..0b6dcaa 100644 --- a/framework/src/main/java/org/toop/framework/networking/NetworkingGameClientHandler.java +++ b/framework/src/main/java/org/toop/framework/networking/NetworkingGameClientHandler.java @@ -2,19 +2,224 @@ package org.toop.framework.networking; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.networking.events.NetworkEvents; public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter { private static final Logger logger = LogManager.getLogger(NetworkingGameClientHandler.class); - public NetworkingGameClientHandler() {} + private final long connectionId; + + public NetworkingGameClientHandler(long connectionId) { + this.connectionId = connectionId; + } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { - logger.debug("Received message from server-{}, data: {}", ctx.channel().remoteAddress(), msg); + String rec = msg.toString().trim(); - // TODO: Handle server messages + if (rec.equalsIgnoreCase("err")) { + logger.error("server-{} send back error, data: {}", ctx.channel().remoteAddress(), msg); + return; + } + if (rec.equalsIgnoreCase("ok")) { + logger.info( + "Received OK message from server-{}, data: {}", + ctx.channel().remoteAddress(), + msg); + return; + } + if (rec.toLowerCase().startsWith("svr")) { + logger.info( + "Received SVR message from server-{}, data: {}", + ctx.channel().remoteAddress(), + msg); + new EventFlow() + .addPostEvent(new NetworkEvents.ServerResponse(this.connectionId)) + .asyncPostEvent(); + parseServerReturn(rec); + return; + } + logger.info( + "Received unparsed message from server-{}, data: {}", + ctx.channel().remoteAddress(), + msg); + } + + private void parseServerReturn(String rec) { + + String recSrvRemoved = rec.substring("SVR ".length()); + Pattern gamePattern = Pattern.compile("GAME (\\w+)", Pattern.CASE_INSENSITIVE); + Matcher gameMatch = gamePattern.matcher(recSrvRemoved); + + if (gameMatch.find()) { + switch (gameMatch.group(1)) { + case "YOURTURN": + gameYourTurnHandler(recSrvRemoved); + return; + case "MOVE": + gameMoveHandler(recSrvRemoved); + return; + case "MATCH": + gameMatchHandler(recSrvRemoved); + return; + case "CHALLENGE": + gameChallengeHandler(recSrvRemoved); + return; + case "WIN", "DRAW", "LOSE": + gameWinConditionHandler(recSrvRemoved); + return; + default: + return; + } + } else { + + Pattern getPattern = Pattern.compile("(\\w+)", Pattern.CASE_INSENSITIVE); + Matcher getMatch = getPattern.matcher(recSrvRemoved); + + if (getMatch.find()) { + switch (getMatch.group(1)) { + case "PLAYERLIST": + playerlistHandler(recSrvRemoved); + return; + case "GAMELIST": + gamelistHandler(recSrvRemoved); + return; + case "HELP": + helpHandler(recSrvRemoved); + return; + default: + return; + } + } else { + return; // TODO: Should be an error. + } + } + } + + private void gameMoveHandler(String rec) { + String[] msg = + Pattern.compile( + "(?:player|details|move):\\s*\"?([^\",}]+)\"?", + Pattern.CASE_INSENSITIVE) + .matcher(rec) + .results() + .map(m -> m.group(1).trim()) + .toArray(String[]::new); + + new EventFlow() + .addPostEvent( + new NetworkEvents.GameMoveResponse( + this.connectionId, msg[0], msg[1], msg[2])) + .asyncPostEvent(); + } + + private void gameWinConditionHandler(String rec) { + String condition = + Pattern.compile("\\b(win|draw|lose)\\b", Pattern.CASE_INSENSITIVE) + .matcher(rec) + .results() + .toString() + .trim(); + + new EventFlow() + .addPostEvent(new NetworkEvents.GameResultResponse(this.connectionId, condition)) + .asyncPostEvent(); + } + + private void gameChallengeHandler(String rec) { + boolean isCancelled = rec.toLowerCase().startsWith("challenge accepted"); + try { + String[] msg = + Pattern.compile( + "(?:CHALLENGER|GAMETYPE|CHALLENGENUMBER):\\s*\"?(.*?)\"?\\s*(?:,|})") + .matcher(rec) + .results() + .map(m -> m.group().trim()) + .toArray(String[]::new); + + if (isCancelled) + new EventFlow() + .addPostEvent( + new NetworkEvents.ChallengeCancelledResponse( + this.connectionId, msg[0])) + .asyncPostEvent(); + else + new EventFlow() + .addPostEvent( + new NetworkEvents.ChallengeResponse( + this.connectionId, msg[0], msg[1], msg[2])) + .asyncPostEvent(); + } catch (ArrayIndexOutOfBoundsException e) { + logger.error("Array out of bounds for: {}", rec, e); + } + } + + private void gameMatchHandler(String rec) { + try { + String[] msg = + Pattern.compile("\"([^\"]*)\"") + .matcher(rec) + .results() + .map(m -> m.group(1).trim()) + .toArray(String[]::new); + + // [0] playerToMove, [1] gameType, [2] opponent + new EventFlow() + .addPostEvent( + new NetworkEvents.GameMatchResponse( + this.connectionId, msg[0], msg[1], msg[2])) + .asyncPostEvent(); + } catch (ArrayIndexOutOfBoundsException e) { + logger.error("Array out of bounds for: {}", rec, e); + } + } + + private void gameYourTurnHandler(String rec) { + String msg = + Pattern.compile("TURNMESSAGE:\\s*\"([^\"]*)\"") + .matcher(rec) + .results() + .toString() + .trim(); + + new EventFlow() + .addPostEvent(new NetworkEvents.YourTurnResponse(this.connectionId, msg)) + .asyncPostEvent(); + } + + private void playerlistHandler(String rec) { + String[] players = + Pattern.compile("\"([^\"]+)\"") + .matcher(rec) + .results() + .map(m -> m.group(1).trim()) + .toArray(String[]::new); + + new EventFlow() + .addPostEvent(new NetworkEvents.PlayerlistResponse(this.connectionId, players)) + .asyncPostEvent(); + } + + private void gamelistHandler(String rec) { + String[] gameTypes = + Pattern.compile("\"([^\"]+)\"") + .matcher(rec) + .results() + .map(m -> m.group(1).trim()) + .toArray(String[]::new); + + new EventFlow() + .addPostEvent(new NetworkEvents.GamelistResponse(this.connectionId, gameTypes)) + .asyncPostEvent(); + } + + private void helpHandler(String rec) { + logger.info(rec); } @Override @@ -22,5 +227,4 @@ public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter { logger.error(cause.getMessage(), cause); ctx.close(); } - } diff --git a/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java b/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java index 18fbd26..df59a69 100644 --- a/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java +++ b/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java @@ -1,93 +1,143 @@ package org.toop.framework.networking.events; -import org.toop.framework.eventbus.events.EventWithSnowflake; -import org.toop.framework.eventbus.events.EventWithoutSnowflake; -import org.toop.framework.eventbus.events.EventsBase; -import org.toop.framework.networking.NetworkingGameClientHandler; - import java.lang.reflect.RecordComponent; import java.util.*; import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.toop.framework.eventbus.events.EventWithSnowflake; +import org.toop.framework.eventbus.events.EventWithoutSnowflake; +import org.toop.framework.eventbus.events.EventsBase; +import org.toop.framework.networking.NetworkingClient; +/** + * A collection of networking-related event records for use with the {@link + * org.toop.framework.eventbus.GlobalEventBus}. + * + *

This class defines all the events that can be posted or listened to in the networking + * subsystem. Events are separated into those with unique IDs (EventWithSnowflake) and those without + * (EventWithoutSnowflake). + */ public class NetworkEvents extends EventsBase { /** - * BLOCKING Requests all active connections. The result is returned via the provided - * CompletableFuture. + * Requests all active client connections. * - * @param future List of all connections in string form. + *

This is a blocking event. The result will be delivered via the provided {@link + * CompletableFuture}. + * + * @param future CompletableFuture to receive the list of active {@link NetworkingClient} + * instances. */ - public record RequestsAllClients(CompletableFuture future) implements EventWithoutSnowflake {} + public record RequestsAllClients(CompletableFuture> future) + implements EventWithoutSnowflake {} - /** Forces closing all active connections immediately. */ + /** Forces all active client connections to close immediately. */ public record ForceCloseAllClients() implements EventWithoutSnowflake {} - public record CloseClientRequest(CompletableFuture future) implements EventWithoutSnowflake {} + /** Response indicating a challenge was cancelled. */ + public record ChallengeCancelledResponse(long clientId, String challengeId) + implements EventWithoutSnowflake {} - public record CloseClient(String connectionId) implements EventWithoutSnowflake {} + /** Response indicating a challenge was received. */ + public record ChallengeResponse( + long clientId, String challengerName, String gameType, String challengeId) + implements EventWithoutSnowflake {} + + /** Response containing a list of players for a client. */ + public record PlayerlistResponse(long clientId, String[] playerlist) + implements EventWithoutSnowflake {} + + /** Response containing a list of games for a client. */ + public record GamelistResponse(long clientId, String[] gamelist) + implements EventWithoutSnowflake {} + + /** Response indicating a game match information for a client. */ + public record GameMatchResponse( + long clientId, String playerToMove, String gameType, String opponent) + implements EventWithoutSnowflake {} + + /** Response indicating the result of a game. */ + public record GameResultResponse(long clientId, String condition) + implements EventWithoutSnowflake {} + + /** Response indicating a game move occurred. */ + public record GameMoveResponse(long clientId, String player, String details, String move) + implements EventWithoutSnowflake {} + + /** Response indicating it is the player's turn. */ + public record YourTurnResponse(long clientId, String message) + implements EventWithoutSnowflake {} + + /** Request to send login credentials for a client. */ + public record SendLogin(long clientId, String username) implements EventWithoutSnowflake {} + + /** Request to log out a client. */ + public record SendLogout(long clientId) implements EventWithoutSnowflake {} + + /** Request to retrieve the player list for a client. */ + public record SendGetPlayerlist(long clientId) implements EventWithoutSnowflake {} + + /** Request to retrieve the game list for a client. */ + public record SendGetGamelist(long clientId) implements EventWithoutSnowflake {} + + /** Request to subscribe a client to a game type. */ + public record SendSubscribe(long clientId, String gameType) implements EventWithoutSnowflake {} + + /** Request to make a move in a game. */ + public record SendMove(long clientId, short moveNumber) implements EventWithoutSnowflake {} + + /** Request to challenge another player. */ + public record SendChallenge(long clientId, String usernameToChallenge, String gameType) + implements EventWithoutSnowflake {} + + /** Request to accept a challenge. */ + public record SendAcceptChallenge(long clientId, int challengeId) + implements EventWithoutSnowflake {} + + /** Request to forfeit a game. */ + public record SendForfeit(long clientId) implements EventWithoutSnowflake {} + + /** Request to send a message from a client. */ + public record SendMessage(long clientId, String message) implements EventWithoutSnowflake {} + + /** Request to display help to a client. */ + public record SendHelp(long clientId) implements EventWithoutSnowflake {} + + /** Request to display help for a specific command. */ + public record SendHelpForCommand(long clientId, String command) + implements EventWithoutSnowflake {} + + /** Request to close a specific client connection. */ + public record CloseClient(long clientId) implements EventWithoutSnowflake {} /** - * Event to start a new client connection to a server. - *

- * This event is typically posted to the {@code GlobalEventBus} to initiate the creation of - * a client connection, and carries all information needed to establish that connection: - *
- * - A factory for creating the Netty handler that will manage the connection - *
- * - The server's IP address and port - *
- * - A unique event identifier for correlation with follow-up events - *

+ * Event to start a new client connection. * - *

- * The {@link #eventSnowflake()} allows callers to correlate the {@code StartClient} event - * with subsequent success/failure events. For example, a {@code StartClientSuccess} - * or {@code StartClientFailure} event may carry the same {@code eventId}. - *

+ *

Carries IP, port, and a unique event ID for correlation with responses. * - * @param handlerFactory Factory for constructing a {@link NetworkingGameClientHandler}. - * @param ip The IP address of the server to connect to. - * @param port The port number of the server to connect to. - * @param eventSnowflake A unique identifier for this event, typically injected - * automatically by the {@link org.toop.framework.eventbus.EventFlow}. + * @param ip Server IP address. + * @param port Server port. + * @param eventSnowflake Unique event identifier for correlation. */ - public record StartClient( - Supplier handlerFactory, - String ip, - int port, - long eventSnowflake - ) implements EventWithSnowflake { + public record StartClient(String ip, int port, long eventSnowflake) + implements EventWithSnowflake { - /** - * Returns a map representation of this event, where keys are record component names - * and values are their corresponding values. Useful for generic logging, debugging, - * or serializing events without hardcoding field names. - * - * @return a {@code Map} containing field names and values - */ @Override public Map result() { return Stream.of(this.getClass().getRecordComponents()) - .collect(Collectors.toMap( - RecordComponent::getName, - rc -> { - try { - return rc.getAccessor().invoke(this); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - )); + .collect( + Collectors.toMap( + RecordComponent::getName, + rc -> { + try { + return rc.getAccessor().invoke(this); + } catch (Exception e) { + throw new RuntimeException(e); + } + })); } - /** - * Returns the unique event identifier used for correlating this event. - * - * @return the event ID string - */ @Override public long eventSnowflake() { return this.eventSnowflake; @@ -95,37 +145,26 @@ public class NetworkEvents extends EventsBase { } /** - * TODO: Update docs new input. - * BLOCKING Triggers starting a server connection and returns a future. + * Response confirming a client was started. * - * @param ip The IP address of the server to connect to. - * @param port The port of the server to connect to. - * @param future Returns the UUID of the connection, when connection is established. + * @param clientId The client ID assigned to the new connection. + * @param eventSnowflake Event ID used for correlation. */ - public record StartClientRequest( - Supplier handlerFactory, - String ip, int port, CompletableFuture future) implements EventWithoutSnowflake {} - - /** - * - * @param clientId The ID of the client to be used in requests. - * @param eventSnowflake The eventID used in checking if event is for you. - */ - public record StartClientSuccess(String clientId, long eventSnowflake) + public record StartClientResponse(long clientId, long eventSnowflake) implements EventWithSnowflake { @Override public Map result() { return Stream.of(this.getClass().getRecordComponents()) - .collect(Collectors.toMap( - RecordComponent::getName, - rc -> { - try { - return rc.getAccessor().invoke(this); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - )); + .collect( + Collectors.toMap( + RecordComponent::getName, + rc -> { + try { + return rc.getAccessor().invoke(this); + } catch (Exception e) { + throw new RuntimeException(e); + } + })); } @Override @@ -134,47 +173,41 @@ public class NetworkEvents extends EventsBase { } } + /** Generic server response. */ + public record ServerResponse(long clientId) implements EventWithoutSnowflake {} + /** - * Triggers sending a command to a server. + * Request to send a command to a server. * - * @param connectionId The UUID of the connection to send the command on. + * @param clientId The client connection ID. * @param args The command arguments. */ - public record SendCommand(String connectionId, String... args) implements EventWithoutSnowflake {} - /** - * Triggers reconnecting to a previous address. - * - * @param connectionId The identifier of the connection being reconnected. - */ - public record Reconnect(Object connectionId) implements EventWithoutSnowflake {} + public record SendCommand(long clientId, String... args) implements EventWithoutSnowflake {} + /** WIP (Not working) Request to reconnect a client to a previous address. */ + public record Reconnect(long clientId) implements EventWithoutSnowflake {} /** - * Triggers when the server client receives a message. + * Response triggered when a message is received from a server. * - * @param ConnectionUuid The UUID of the connection that received the message. - * @param message The message received. + * @param clientId The connection ID that received the message. + * @param message The message content. */ - public record ReceivedMessage(String ConnectionUuid, String message) implements EventWithoutSnowflake {} + public record ReceivedMessage(long clientId, String message) implements EventWithoutSnowflake {} /** - * Triggers changing connection to a new address. + * Request to change a client connection to a new server. * - * @param connectionId The identifier of the connection being changed. - * @param ip The new IP address. - * @param port The new port. + * @param clientId The client connection ID. + * @param ip The new server IP. + * @param port The new server port. */ - public record ChangeClient(Object connectionId, String ip, int port) implements EventWithoutSnowflake {} + public record ChangeClientHost(long clientId, String ip, int port) + implements EventWithoutSnowflake {} + /** WIP (Not working) Response indicating that the client could not connect. */ + public record CouldNotConnect(long clientId) implements EventWithoutSnowflake {} - /** - * Triggers when the server couldn't connect to the desired address. - * - * @param connectionId The identifier of the connection that failed. - */ - public record CouldNotConnect(Object connectionId) implements EventWithoutSnowflake {} - - /** WIP Triggers when a connection closes. */ - public record ClosedConnection() implements EventWithoutSnowflake {} - + /** Event indicating a client connection was closed. */ + public record ClosedConnection(long clientId) implements EventWithoutSnowflake {} } diff --git a/framework/src/test/LoggingTest.java b/framework/src/test/java/org/toop/framework/LoggingTest.java similarity index 96% rename from framework/src/test/LoggingTest.java rename to framework/src/test/java/org/toop/framework/LoggingTest.java index af2f733..4629435 100644 --- a/framework/src/test/LoggingTest.java +++ b/framework/src/test/java/org/toop/framework/LoggingTest.java @@ -1,4 +1,4 @@ -package org.toop; +package org.toop.framework; import static org.junit.jupiter.api.Assertions.*; @@ -9,7 +9,6 @@ import org.apache.logging.log4j.core.config.LoggerConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.toop.framework.Logging; public class LoggingTest { @@ -106,6 +105,6 @@ public class LoggingTest { LoggerConfig loggerConfig = ctx.getConfiguration().getLoggers().get("org.toop.DoesNotExist"); - assertNull(loggerConfig); // class doesn't exist, so no logger added + assertNull(loggerConfig); } } diff --git a/framework/src/test/java/org/toop/framework/SnowflakeGeneratorTest.java b/framework/src/test/java/org/toop/framework/SnowflakeGeneratorTest.java new file mode 100644 index 0000000..eb88994 --- /dev/null +++ b/framework/src/test/java/org/toop/framework/SnowflakeGeneratorTest.java @@ -0,0 +1,78 @@ +package org.toop.framework; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class SnowflakeGeneratorTest { + + @Test + void testMachineIdWithinBounds() { + SnowflakeGenerator generator = new SnowflakeGenerator(); + long machineIdField = getMachineId(generator); + assertTrue( + machineIdField >= 0 && machineIdField <= 1023, + "Machine ID should be within 0-1023"); + } + + @Test + void testNextIdReturnsUniqueValues() { + SnowflakeGenerator generator = new SnowflakeGenerator(); + Set ids = new HashSet<>(); + for (int i = 0; i < 1000; i++) { + long id = generator.nextId(); + assertFalse(ids.contains(id), "Duplicate ID generated"); + ids.add(id); + } + } + + @Test + void testSequenceRollover() throws Exception { + SnowflakeGenerator generator = + new SnowflakeGenerator() { + private long fakeTime = System.currentTimeMillis(); + + protected long timestamp() { + return fakeTime; + } + + void incrementTime() { + fakeTime++; + } + }; + + long first = generator.nextId(); + long second = generator.nextId(); + assertNotEquals( + first, second, "IDs generated within same millisecond should differ by sequence"); + + // Force sequence overflow + for (int i = 0; i < (1 << 12); i++) generator.nextId(); + long afterOverflow = generator.nextId(); + assertTrue(afterOverflow > second, "ID after sequence rollover should be greater"); + } + + @Test + void testNextIdMonotonic() { + SnowflakeGenerator generator = new SnowflakeGenerator(); + long prev = generator.nextId(); + for (int i = 0; i < 100; i++) { + long next = generator.nextId(); + assertTrue(next > prev, "IDs must be increasing"); + prev = next; + } + } + + // Helper: reflectively get machineId + private long getMachineId(SnowflakeGenerator generator) { + try { + var field = SnowflakeGenerator.class.getDeclaredField("machineId"); + field.setAccessible(true); + return (long) field.get(generator); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/framework/src/test/java/org/toop/framework/eventbus/EventFlowStressTest.java b/framework/src/test/java/org/toop/framework/eventbus/EventFlowStressTest.java new file mode 100644 index 0000000..7664c01 --- /dev/null +++ b/framework/src/test/java/org/toop/framework/eventbus/EventFlowStressTest.java @@ -0,0 +1,253 @@ +// package org.toop.framework.eventbus; +// +// import org.junit.jupiter.api.Tag; +// import org.junit.jupiter.api.Test; +// import org.toop.framework.eventbus.events.EventWithSnowflake; +// +// import java.math.BigInteger; +// import java.util.concurrent.*; +// import java.util.concurrent.atomic.LongAdder; +// +// import static org.junit.jupiter.api.Assertions.assertEquals; +// +// class EventFlowStressTest { +// +// /** Top-level record to ensure runtime type matches subscription */ +// public record HeavyEvent(String payload, long eventSnowflake) implements EventWithSnowflake { +// @Override +// public java.util.Map result() { +// return java.util.Map.of("payload", payload, "eventId", eventSnowflake); +// } +// +// @Override +// public long eventSnowflake() { +// return this.eventSnowflake; +// } +// } +// +// public record HeavyEventSuccess(String payload, long eventSnowflake) implements +// EventWithSnowflake { +// @Override +// public java.util.Map result() { +// return java.util.Map.of("payload", payload, "eventId", eventSnowflake); +// } +// +// @Override +// public long eventSnowflake() { +// return eventSnowflake; +// } +// } +// +// private static final int THREADS = 32; +// private static final long EVENTS_PER_THREAD = 10_000_000; +// +// @Tag("stress") +// @Test +// void extremeConcurrencySendTest_progressWithMemory() throws InterruptedException { +// LongAdder counter = new LongAdder(); +// ExecutorService executor = Executors.newFixedThreadPool(THREADS); +// +// BigInteger totalEvents = BigInteger.valueOf(THREADS) +// .multiply(BigInteger.valueOf(EVENTS_PER_THREAD)); +// +// long startTime = System.currentTimeMillis(); +// +// // Monitor thread for EPS and memory +// Thread monitor = new Thread(() -> { +// long lastCount = 0; +// long lastTime = System.currentTimeMillis(); +// Runtime runtime = Runtime.getRuntime(); +// +// while (counter.sum() < totalEvents.longValue()) { +// try { Thread.sleep(200); } catch (InterruptedException ignored) {} +// +// long now = System.currentTimeMillis(); +// long completed = counter.sum(); +// long eventsThisPeriod = completed - lastCount; +// double eps = eventsThisPeriod / ((now - lastTime) / 1000.0); +// +// long usedMemory = runtime.totalMemory() - runtime.freeMemory(); +// double usedPercent = usedMemory * 100.0 / runtime.maxMemory(); +// +// System.out.printf( +// "Progress: %d/%d (%.2f%%), EPS: %.0f, Memory Used: %.2f MB (%.2f%%)%n", +// completed, +// totalEvents.longValue(), +// completed * 100.0 / totalEvents.doubleValue(), +// eps, +// usedMemory / 1024.0 / 1024.0, +// usedPercent +// ); +// +// lastCount = completed; +// lastTime = now; +// } +// }); +// monitor.setDaemon(true); +// monitor.start(); +// +// var listener = new EventFlow().listen(HeavyEvent.class, _ -> counter.increment()); +// +// // Submit events asynchronously +// for (int t = 0; t < THREADS; t++) { +// executor.submit(() -> { +// for (int i = 0; i < EVENTS_PER_THREAD; i++) { +// var _ = new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i) +// .asyncPostEvent(); +// } +// }); +// } +// +// executor.shutdown(); +// executor.awaitTermination(10, TimeUnit.MINUTES); +// +// listener.getResult(); +// +// long endTime = System.currentTimeMillis(); +// double durationSeconds = (endTime - startTime) / 1000.0; +// +// System.out.println("Posted " + totalEvents + " events in " + durationSeconds + " +// seconds"); +// double averageEps = totalEvents.doubleValue() / durationSeconds; +// System.out.printf("Average EPS: %.0f%n", averageEps); +// +// assertEquals(totalEvents.longValue(), counter.sum()); +// } +// +// @Tag("stress") +// @Test +// void extremeConcurrencySendAndReturnTest_progressWithMemory() throws InterruptedException { +// LongAdder counter = new LongAdder(); +// ExecutorService executor = Executors.newFixedThreadPool(THREADS); +// +// BigInteger totalEvents = BigInteger.valueOf(THREADS) +// .multiply(BigInteger.valueOf(EVENTS_PER_THREAD)); +// +// long startTime = System.currentTimeMillis(); +// +// // Monitor thread for EPS and memory +// Thread monitor = new Thread(() -> { +// long lastCount = 0; +// long lastTime = System.currentTimeMillis(); +// Runtime runtime = Runtime.getRuntime(); +// +// while (counter.sum() < totalEvents.longValue()) { +// try { Thread.sleep(200); } catch (InterruptedException ignored) {} +// +// long now = System.currentTimeMillis(); +// long completed = counter.sum(); +// long eventsThisPeriod = completed - lastCount; +// double eps = eventsThisPeriod / ((now - lastTime) / 1000.0); +// +// long usedMemory = runtime.totalMemory() - runtime.freeMemory(); +// double usedPercent = usedMemory * 100.0 / runtime.maxMemory(); +// +// System.out.printf( +// "Progress: %d/%d (%.2f%%), EPS: %.0f, Memory Used: %.2f MB (%.2f%%)%n", +// completed, +// totalEvents.longValue(), +// completed * 100.0 / totalEvents.doubleValue(), +// eps, +// usedMemory / 1024.0 / 1024.0, +// usedPercent +// ); +// +// lastCount = completed; +// lastTime = now; +// } +// }); +// monitor.setDaemon(true); +// monitor.start(); +// +// // Submit events asynchronously +// for (int t = 0; t < THREADS; t++) { +// executor.submit(() -> { +// for (int i = 0; i < EVENTS_PER_THREAD; i++) { +// var a = new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i) +// .onResponse(HeavyEventSuccess.class, _ -> counter.increment()) +// .postEvent(); +// +// new EventFlow().addPostEvent(HeavyEventSuccess.class, "payload-" + i, +// a.getEventSnowflake()) +// .postEvent(); +// } +// }); +// } +// +// executor.shutdown(); +// executor.awaitTermination(10, TimeUnit.MINUTES); +// +// long endTime = System.currentTimeMillis(); +// double durationSeconds = (endTime - startTime) / 1000.0; +// +// System.out.println("Posted " + totalEvents + " events in " + durationSeconds + " +// seconds"); +// double averageEps = totalEvents.doubleValue() / durationSeconds; +// System.out.printf("Average EPS: %.0f%n", averageEps); +// +// assertEquals(totalEvents.longValue(), counter.sum()); +// } +// +// +// @Tag("stress") +// @Test +// void efficientExtremeConcurrencyTest() throws InterruptedException { +// final int THREADS = Runtime.getRuntime().availableProcessors(); +// final int EVENTS_PER_THREAD = 5000; +// +// ExecutorService executor = Executors.newFixedThreadPool(THREADS); +// ConcurrentLinkedQueue processedEvents = new ConcurrentLinkedQueue<>(); +// +// long start = System.nanoTime(); +// +// for (int t = 0; t < THREADS; t++) { +// executor.submit(() -> { +// for (int i = 0; i < EVENTS_PER_THREAD; i++) { +// new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i) +// .onResponse(HeavyEvent.class, processedEvents::add) +// .postEvent(); +// } +// }); +// } +// +// executor.shutdown(); +// executor.awaitTermination(10, TimeUnit.MINUTES); +// +// long end = System.nanoTime(); +// double durationSeconds = (end - start) / 1_000_000_000.0; +// +// BigInteger totalEvents = BigInteger.valueOf((long) +// THREADS).multiply(BigInteger.valueOf(EVENTS_PER_THREAD)); +// double eps = totalEvents.doubleValue() / durationSeconds; +// +// System.out.printf("Posted %s events in %.3f seconds%n", totalEvents, durationSeconds); +// System.out.printf("Throughput: %.0f events/sec%n", eps); +// +// Runtime rt = Runtime.getRuntime(); +// System.out.printf("Used memory: %.2f MB%n", (rt.totalMemory() - rt.freeMemory()) / 1024.0 +// / 1024.0); +// +// assertEquals(totalEvents.intValue(), processedEvents.size()); +// } +// +// @Tag("stress") +// @Test +// void constructorCacheVsReflection() throws Throwable { +// int iterations = 1_000_000; +// long startReflect = System.nanoTime(); +// for (int i = 0; i < iterations; i++) { +// HeavyEvent.class.getDeclaredConstructors()[0].newInstance("payload", "uuid-" + i); +// } +// long endReflect = System.nanoTime(); +// +// long startHandle = System.nanoTime(); +// for (int i = 0; i < iterations; i++) { +// EventFlow a = new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i); +// } +// long endHandle = System.nanoTime(); +// +// System.out.println("Reflection: " + (endReflect - startReflect) / 1_000_000 + " ms"); +// System.out.println("MethodHandle Cache: " + (endHandle - startHandle) / 1_000_000 + " +// ms"); +// } +// } diff --git a/framework/src/test/java/org/toop/framework/eventbus/EventPublisherTest.java b/framework/src/test/java/org/toop/framework/eventbus/EventFlowTest.java similarity index 76% rename from framework/src/test/java/org/toop/framework/eventbus/EventPublisherTest.java rename to framework/src/test/java/org/toop/framework/eventbus/EventFlowTest.java index 2a29dd0..26c50a6 100644 --- a/framework/src/test/java/org/toop/framework/eventbus/EventPublisherTest.java +++ b/framework/src/test/java/org/toop/framework/eventbus/EventFlowTest.java @@ -1,30 +1,31 @@ package org.toop.framework.eventbus; -import org.junit.jupiter.api.Test; -import org.toop.framework.eventbus.events.EventWithSnowflake; +import static org.junit.jupiter.api.Assertions.*; import java.util.HashSet; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import org.toop.framework.SnowflakeGenerator; +import org.toop.framework.eventbus.events.EventWithSnowflake; class EventFlowTest { @Test void testSnowflakeStructure() { - long id = new SnowflakeGenerator(1).nextId(); + long id = new SnowflakeGenerator().nextId(); long timestampPart = id >>> 22; long randomPart = id & ((1L << 22) - 1); assertTrue(timestampPart > 0, "Timestamp part should be non-zero"); - assertTrue(randomPart >= 0 && randomPart < (1L << 22), "Random part should be within 22 bits"); + assertTrue( + randomPart >= 0 && randomPart < (1L << 22), "Random part should be within 22 bits"); } @Test void testSnowflakeMonotonicity() throws InterruptedException { - SnowflakeGenerator sf = new SnowflakeGenerator(1); + SnowflakeGenerator sf = new SnowflakeGenerator(); long id1 = sf.nextId(); Thread.sleep(1); // ensure timestamp increases long id2 = sf.nextId(); @@ -34,7 +35,7 @@ class EventFlowTest { @Test void testSnowflakeUniqueness() { - SnowflakeGenerator sf = new SnowflakeGenerator(1); + SnowflakeGenerator sf = new SnowflakeGenerator(); Set ids = new HashSet<>(); for (int i = 0; i < 100_000; i++) { long id = sf.nextId(); @@ -45,9 +46,20 @@ class EventFlowTest { // --- Dummy Event classes for testing --- static class DummySnowflakeEvent implements EventWithSnowflake { private final long snowflake; - DummySnowflakeEvent(long snowflake) { this.snowflake = snowflake; } - @Override public long eventSnowflake() { return snowflake; } - @Override public java.util.Map result() { return java.util.Collections.emptyMap(); } + + DummySnowflakeEvent(long snowflake) { + this.snowflake = snowflake; + } + + @Override + public long eventSnowflake() { + return snowflake; + } + + @Override + public java.util.Map result() { + return java.util.Collections.emptyMap(); + } } @Test @@ -55,7 +67,7 @@ class EventFlowTest { EventFlow flow = new EventFlow(); flow.addPostEvent(DummySnowflakeEvent.class); // no args, should auto-generate - long id = flow.getEventId(); + long id = flow.getEventSnowflake(); assertNotEquals(-1, id, "Snowflake should be auto-generated"); assertTrue(flow.getEvent() instanceof DummySnowflakeEvent); assertEquals(id, ((DummySnowflakeEvent) flow.getEvent()).eventSnowflake()); @@ -74,7 +86,7 @@ class EventFlowTest { assertFalse(handlerCalled.get(), "Handler should not fire for mismatched snowflake"); // Post with matching snowflake - GlobalEventBus.post(new DummySnowflakeEvent(flow.getEventId())); + GlobalEventBus.post(new DummySnowflakeEvent(flow.getEventSnowflake())); assertTrue(handlerCalled.get(), "Handler should fire for matching snowflake"); } -} \ No newline at end of file +} diff --git a/framework/src/test/java/org/toop/framework/eventbus/EventPublisherSpeedTest.java b/framework/src/test/java/org/toop/framework/eventbus/EventPublisherSpeedTest.java deleted file mode 100644 index 0a20721..0000000 --- a/framework/src/test/java/org/toop/framework/eventbus/EventPublisherSpeedTest.java +++ /dev/null @@ -1,88 +0,0 @@ -//package org.toop.framework.eventbus; -// -//import org.junit.jupiter.api.Test; -//import org.toop.framework.eventbus.events.EventWithUuid; -// -//import java.util.concurrent.atomic.AtomicInteger; -// -//import static org.junit.jupiter.api.Assertions.assertTrue; -// -//class EventPublisherPerformanceTest { -// -// public record PerfEvent(String name, String eventId) implements EventWithUuid { -// @Override -// public java.util.Map result() { -// return java.util.Map.of("name", name, "eventId", eventId); -// } -// } -// -// @Test -// void testEventCreationSpeed() { -// int iterations = 10_000; -// long start = System.nanoTime(); -// -// for (int i = 0; i < iterations; i++) { -// new EventPublisher<>(PerfEvent.class, "event-" + i); -// } -// -// long end = System.nanoTime(); -// long durationMs = (end - start) / 1_000_000; -// -// System.out.println("Created " + iterations + " events in " + durationMs + " ms"); -// assertTrue(durationMs < 500, "Event creation too slow"); -// } -// -// @Test -// void testEventPostSpeed() { -// int iterations = 100_000; -// AtomicInteger counter = new AtomicInteger(0); -// -// GlobalEventBus.subscribe(PerfEvent.class, e -> counter.incrementAndGet()); -// -// long start = System.nanoTime(); -// -// for (int i = 0; i < iterations; i++) { -// new EventPublisher<>(PerfEvent.class, "event-" + i).postEvent(); -// } -// -// long end = System.nanoTime(); -// long durationMs = (end - start) / 1_000_000; -// -// System.out.println("Posted " + iterations + " events in " + durationMs + " ms"); -// assertTrue(counter.get() == iterations, "Not all events were received"); -// assertTrue(durationMs < 1000, "Posting events too slow"); -// } -// -// @Test -// void testConcurrentEventPostSpeed() throws InterruptedException { -// int threads = 20; -// int eventsPerThread = 5_000; -// AtomicInteger counter = new AtomicInteger(0); -// -// GlobalEventBus.subscribe(PerfEvent.class, e -> counter.incrementAndGet()); -// -// Thread[] workers = new Thread[threads]; -// -// long start = System.nanoTime(); -// -// for (int t = 0; t < threads; t++) { -// workers[t] = new Thread(() -> { -// for (int i = 0; i < eventsPerThread; i++) { -// new EventPublisher<>(PerfEvent.class, "event-" + i).postEvent(); -// } -// }); -// workers[t].start(); -// } -// -// for (Thread worker : workers) { -// worker.join(); -// } -// -// long end = System.nanoTime(); -// long durationMs = (end - start) / 1_000_000; -// -// System.out.println("Posted " + (threads * eventsPerThread) + " events concurrently in " + durationMs + " ms"); -// assertTrue(counter.get() == threads * eventsPerThread, "Some events were lost"); -// assertTrue(durationMs < 5000, "Concurrent posting too slow"); -// } -//} \ No newline at end of file diff --git a/framework/src/test/java/org/toop/framework/eventbus/EventPublisherStressTest.java b/framework/src/test/java/org/toop/framework/eventbus/EventPublisherStressTest.java deleted file mode 100644 index efd9ce9..0000000 --- a/framework/src/test/java/org/toop/framework/eventbus/EventPublisherStressTest.java +++ /dev/null @@ -1,247 +0,0 @@ -package org.toop.framework.eventbus; - -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.toop.framework.eventbus.events.EventWithSnowflake; - -import java.math.BigInteger; -import java.util.concurrent.*; -import java.util.concurrent.atomic.LongAdder; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class EventPublisherStressTest { - - /** Top-level record to ensure runtime type matches subscription */ - public record HeavyEvent(String payload, long eventSnowflake) implements EventWithSnowflake { - @Override - public java.util.Map result() { - return java.util.Map.of("payload", payload, "eventId", eventSnowflake); - } - - @Override - public long eventSnowflake() { - return this.eventSnowflake; - } - } - - public record HeavyEventSuccess(String payload, long eventSnowflake) implements EventWithSnowflake { - @Override - public java.util.Map result() { - return java.util.Map.of("payload", payload, "eventId", eventSnowflake); - } - - @Override - public long eventSnowflake() { - return eventSnowflake; - } - } - - private static final int THREADS = 32; - private static final long EVENTS_PER_THREAD = 10_000_000; - - @Tag("stress") - @Test - void extremeConcurrencySendTest_progressWithMemory() throws InterruptedException { - LongAdder counter = new LongAdder(); - ExecutorService executor = Executors.newFixedThreadPool(THREADS); - - BigInteger totalEvents = BigInteger.valueOf(THREADS) - .multiply(BigInteger.valueOf(EVENTS_PER_THREAD)); - - long startTime = System.currentTimeMillis(); - - // Monitor thread for EPS and memory - Thread monitor = new Thread(() -> { - long lastCount = 0; - long lastTime = System.currentTimeMillis(); - Runtime runtime = Runtime.getRuntime(); - - while (counter.sum() < totalEvents.longValue()) { - try { Thread.sleep(200); } catch (InterruptedException ignored) {} - - long now = System.currentTimeMillis(); - long completed = counter.sum(); - long eventsThisPeriod = completed - lastCount; - double eps = eventsThisPeriod / ((now - lastTime) / 1000.0); - - long usedMemory = runtime.totalMemory() - runtime.freeMemory(); - double usedPercent = usedMemory * 100.0 / runtime.maxMemory(); - - System.out.printf( - "Progress: %d/%d (%.2f%%), EPS: %.0f, Memory Used: %.2f MB (%.2f%%)%n", - completed, - totalEvents.longValue(), - completed * 100.0 / totalEvents.doubleValue(), - eps, - usedMemory / 1024.0 / 1024.0, - usedPercent - ); - - lastCount = completed; - lastTime = now; - } - }); - monitor.setDaemon(true); - monitor.start(); - - var listener = new EventFlow().listen(HeavyEvent.class, _ -> counter.increment()); - - // Submit events asynchronously - for (int t = 0; t < THREADS; t++) { - executor.submit(() -> { - for (int i = 0; i < EVENTS_PER_THREAD; i++) { - var _ = new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i) - .asyncPostEvent(); - } - }); - } - - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.MINUTES); - - listener.getResult(); - - long endTime = System.currentTimeMillis(); - double durationSeconds = (endTime - startTime) / 1000.0; - - System.out.println("Posted " + totalEvents + " events in " + durationSeconds + " seconds"); - double averageEps = totalEvents.doubleValue() / durationSeconds; - System.out.printf("Average EPS: %.0f%n", averageEps); - - assertEquals(totalEvents.longValue(), counter.sum()); - } - - @Tag("stress") - @Test - void extremeConcurrencySendAndReturnTest_progressWithMemory() throws InterruptedException { - LongAdder counter = new LongAdder(); - ExecutorService executor = Executors.newFixedThreadPool(THREADS); - - BigInteger totalEvents = BigInteger.valueOf(THREADS) - .multiply(BigInteger.valueOf(EVENTS_PER_THREAD)); - - long startTime = System.currentTimeMillis(); - - // Monitor thread for EPS and memory - Thread monitor = new Thread(() -> { - long lastCount = 0; - long lastTime = System.currentTimeMillis(); - Runtime runtime = Runtime.getRuntime(); - - while (counter.sum() < totalEvents.longValue()) { - try { Thread.sleep(200); } catch (InterruptedException ignored) {} - - long now = System.currentTimeMillis(); - long completed = counter.sum(); - long eventsThisPeriod = completed - lastCount; - double eps = eventsThisPeriod / ((now - lastTime) / 1000.0); - - long usedMemory = runtime.totalMemory() - runtime.freeMemory(); - double usedPercent = usedMemory * 100.0 / runtime.maxMemory(); - - System.out.printf( - "Progress: %d/%d (%.2f%%), EPS: %.0f, Memory Used: %.2f MB (%.2f%%)%n", - completed, - totalEvents.longValue(), - completed * 100.0 / totalEvents.doubleValue(), - eps, - usedMemory / 1024.0 / 1024.0, - usedPercent - ); - - lastCount = completed; - lastTime = now; - } - }); - monitor.setDaemon(true); - monitor.start(); - - // Submit events asynchronously - for (int t = 0; t < THREADS; t++) { - executor.submit(() -> { - for (int i = 0; i < EVENTS_PER_THREAD; i++) { - var a = new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i) - .onResponse(HeavyEventSuccess.class, _ -> counter.increment()) - .unsubscribeAfterSuccess() - .postEvent(); - - new EventFlow().addPostEvent(HeavyEventSuccess.class, "payload-" + i, a.getEventId()) - .postEvent(); - } - }); - } - - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.MINUTES); - - long endTime = System.currentTimeMillis(); - double durationSeconds = (endTime - startTime) / 1000.0; - - System.out.println("Posted " + totalEvents + " events in " + durationSeconds + " seconds"); - double averageEps = totalEvents.doubleValue() / durationSeconds; - System.out.printf("Average EPS: %.0f%n", averageEps); - - assertEquals(totalEvents.longValue(), counter.sum()); - } - - - @Tag("stress") - @Test - void efficientExtremeConcurrencyTest() throws InterruptedException { - final int THREADS = Runtime.getRuntime().availableProcessors(); - final int EVENTS_PER_THREAD = 5000; - - ExecutorService executor = Executors.newFixedThreadPool(THREADS); - ConcurrentLinkedQueue processedEvents = new ConcurrentLinkedQueue<>(); - - long start = System.nanoTime(); - - for (int t = 0; t < THREADS; t++) { - executor.submit(() -> { - for (int i = 0; i < EVENTS_PER_THREAD; i++) { - new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i) - .onResponse(HeavyEvent.class, processedEvents::add) - .postEvent(); - } - }); - } - - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.MINUTES); - - long end = System.nanoTime(); - double durationSeconds = (end - start) / 1_000_000_000.0; - - BigInteger totalEvents = BigInteger.valueOf((long) THREADS).multiply(BigInteger.valueOf(EVENTS_PER_THREAD)); - double eps = totalEvents.doubleValue() / durationSeconds; - - System.out.printf("Posted %s events in %.3f seconds%n", totalEvents, durationSeconds); - System.out.printf("Throughput: %.0f events/sec%n", eps); - - Runtime rt = Runtime.getRuntime(); - System.out.printf("Used memory: %.2f MB%n", (rt.totalMemory() - rt.freeMemory()) / 1024.0 / 1024.0); - - assertEquals(totalEvents.intValue(), processedEvents.size()); - } - - @Tag("stress") - @Test - void constructorCacheVsReflection() throws Throwable { - int iterations = 1_000_000; - long startReflect = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - HeavyEvent.class.getDeclaredConstructors()[0].newInstance("payload", "uuid-" + i); - } - long endReflect = System.nanoTime(); - - long startHandle = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - EventFlow a = new EventFlow().addPostEvent(HeavyEvent.class, "payload-" + i); - } - long endHandle = System.nanoTime(); - - System.out.println("Reflection: " + (endReflect - startReflect) / 1_000_000 + " ms"); - System.out.println("MethodHandle Cache: " + (endHandle - startHandle) / 1_000_000 + " ms"); - } -} diff --git a/framework/src/test/java/org/toop/framework/eventbus/GlobalEventBusTest.java b/framework/src/test/java/org/toop/framework/eventbus/GlobalEventBusTest.java index 4ed8d8b..4823003 100644 --- a/framework/src/test/java/org/toop/framework/eventbus/GlobalEventBusTest.java +++ b/framework/src/test/java/org/toop/framework/eventbus/GlobalEventBusTest.java @@ -1,110 +1,159 @@ -//package org.toop.framework.eventbus; -// -//import net.engio.mbassy.bus.publication.SyncAsyncPostCommand; -//import org.junit.jupiter.api.AfterEach; -//import org.junit.jupiter.api.Test; -//import org.toop.framework.eventbus.events.IEvent; -// -//import java.util.concurrent.atomic.AtomicBoolean; -//import java.util.concurrent.atomic.AtomicReference; -// -//import static org.junit.jupiter.api.Assertions.*; -// -//class GlobalEventBusTest { -// -// // A simple test event -// static class TestEvent implements IEvent { -// private final String message; -// -// TestEvent(String message) { -// this.message = message; -// } -// -// String getMessage() { -// return message; -// } -// } -// -// @AfterEach -// void tearDown() { -// // Reset to avoid leaking subscribers between tests -// GlobalEventBus.reset(); -// } -// -// @Test -// void testSubscribeWithType() { -// AtomicReference result = new AtomicReference<>(); -// -// GlobalEventBus.subscribe(TestEvent.class, e -> result.set(e.getMessage())); -// -// GlobalEventBus.post(new TestEvent("hello")); -// -// assertEquals("hello", result.get()); -// } -// -// @Test -// void testSubscribeWithoutType() { -// AtomicReference result = new AtomicReference<>(); -// -// GlobalEventBus.subscribe((TestEvent e) -> result.set(e.getMessage())); -// -// GlobalEventBus.post(new TestEvent("world")); -// -// assertEquals("world", result.get()); -// } -// -// @Test -// void testUnsubscribeStopsReceivingEvents() { -// AtomicBoolean called = new AtomicBoolean(false); -// -// Object listener = GlobalEventBus.subscribe(TestEvent.class, e -> called.set(true)); -// -// // First event should trigger -// GlobalEventBus.post(new TestEvent("first")); -// assertTrue(called.get()); -// -// // Reset flag -// called.set(false); -// -// // Unsubscribe and post again -// GlobalEventBus.unsubscribe(listener); -// GlobalEventBus.post(new TestEvent("second")); -// -// assertFalse(called.get(), "Listener should not be called after unsubscribe"); -// } -// -// @Test -// void testResetClearsListeners() { -// AtomicBoolean called = new AtomicBoolean(false); -// -// GlobalEventBus.subscribe(TestEvent.class, e -> called.set(true)); -// -// GlobalEventBus.reset(); // should wipe subscriptions -// -// GlobalEventBus.post(new TestEvent("ignored")); -// -// assertFalse(called.get(), "Listener should not survive reset()"); -// } +package org.toop.framework.eventbus; -// @Test -// void testSetReplacesBus() { -// MBassadorMock mockBus = new MBassadorMock<>(); -// GlobalEventBus.set(mockBus); -// -// TestEvent event = new TestEvent("test"); -// GlobalEventBus.post(event); -// -// assertEquals(event, mockBus.lastPosted, "Custom bus should receive the event"); -// } -// -// // Minimal fake MBassador for verifying set() -// static class MBassadorMock extends net.engio.mbassy.bus.MBassador { -// T lastPosted; -// -// @Override -// public SyncAsyncPostCommand post(T message) { -// this.lastPosted = message; -// return super.post(message); -// } -// } -//} +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.*; +import org.toop.framework.eventbus.events.EventType; +import org.toop.framework.eventbus.events.EventWithSnowflake; + +class GlobalEventBusTest { + + // ------------------------------------------------------------------------ + // Test Events + // ------------------------------------------------------------------------ + private record TestEvent(String message) implements EventType {} + + private record TestSnowflakeEvent(long eventSnowflake, String payload) + implements EventWithSnowflake { + @Override + public java.util.Map result() { + return java.util.Map.of("payload", payload); + } + } + + static class SampleEvent implements EventType { + private final String message; + + SampleEvent(String message) { + this.message = message; + } + + public String message() { + return message; + } + } + + @AfterEach + void cleanup() { + GlobalEventBus.reset(); + } + + // ------------------------------------------------------------------------ + // Subscriptions + // ------------------------------------------------------------------------ + @Test + void testSubscribeAndPost() { + AtomicReference received = new AtomicReference<>(); + Consumer listener = e -> received.set(e.message()); + + GlobalEventBus.subscribe(TestEvent.class, listener); + GlobalEventBus.post(new TestEvent("hello")); + + assertEquals("hello", received.get()); + } + + @Test + void testUnsubscribe() { + GlobalEventBus.reset(); + + AtomicBoolean called = new AtomicBoolean(false); + + // Subscribe and keep the wrapper reference + Consumer subscription = + GlobalEventBus.subscribe(SampleEvent.class, e -> called.set(true)); + + // Post once -> should trigger + GlobalEventBus.post(new SampleEvent("test1")); + assertTrue(called.get(), "Listener should be triggered before unsubscribe"); + + // Reset flag + called.set(false); + + // Unsubscribe using the wrapper reference + GlobalEventBus.unsubscribe(subscription); + + // Post again -> should NOT trigger + GlobalEventBus.post(new SampleEvent("test2")); + assertFalse(called.get(), "Listener should not be triggered after unsubscribe"); + } + + @Test + void testSubscribeGeneric() { + AtomicReference received = new AtomicReference<>(); + Consumer listener = e -> received.set((EventType) e); + + GlobalEventBus.subscribe(listener); + TestEvent event = new TestEvent("generic"); + GlobalEventBus.post(event); + + assertEquals(event, received.get()); + } + + @Test + void testSubscribeById() { + AtomicReference received = new AtomicReference<>(); + long id = 42L; + + GlobalEventBus.subscribeById(TestSnowflakeEvent.class, id, e -> received.set(e.payload())); + GlobalEventBus.post(new TestSnowflakeEvent(id, "snowflake")); + + assertEquals("snowflake", received.get()); + } + + @Test + void testUnsubscribeById() { + AtomicBoolean triggered = new AtomicBoolean(false); + long id = 99L; + + GlobalEventBus.subscribeById(TestSnowflakeEvent.class, id, e -> triggered.set(true)); + GlobalEventBus.unsubscribeById(TestSnowflakeEvent.class, id); + + GlobalEventBus.post(new TestSnowflakeEvent(id, "ignored")); + assertFalse(triggered.get(), "Listener should not be triggered after unsubscribeById"); + } + + // ------------------------------------------------------------------------ + // Async posting + // ------------------------------------------------------------------------ + @Test + void testPostAsync() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + GlobalEventBus.subscribe( + TestEvent.class, + e -> { + if ("async".equals(e.message())) { + latch.countDown(); + } + }); + + GlobalEventBus.postAsync(new TestEvent("async")); + + assertTrue( + latch.await(1, TimeUnit.SECONDS), "Async event should be received within timeout"); + } + + // ------------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------------ + @Test + void testResetClearsListeners() { + AtomicBoolean triggered = new AtomicBoolean(false); + GlobalEventBus.subscribe(TestEvent.class, e -> triggered.set(true)); + + GlobalEventBus.reset(); + GlobalEventBus.post(new TestEvent("ignored")); + + assertFalse(triggered.get(), "Listener should not be triggered after reset"); + } + + @Test + void testShutdown() { + // Should not throw + assertDoesNotThrow(GlobalEventBus::shutdown); + } +} diff --git a/framework/src/test/java/org/toop/framework/networking/NetworkingClientManagerTest.java b/framework/src/test/java/org/toop/framework/networking/NetworkingClientManagerTest.java new file mode 100644 index 0000000..ee40ef3 --- /dev/null +++ b/framework/src/test/java/org/toop/framework/networking/NetworkingClientManagerTest.java @@ -0,0 +1,123 @@ +package org.toop.framework.networking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.*; +import org.mockito.*; +import org.toop.framework.SnowflakeGenerator; +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.networking.events.NetworkEvents; + +class NetworkingClientManagerTest { + + @Mock NetworkingClient mockClient; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testStartClientRequest_withMockedClient() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + long clientId = new SnowflakeGenerator().nextId(); + + // Put the mock client into the map + manager.networkClients.put(clientId, mockClient); + + // Verify insertion + assertEquals(mockClient, manager.networkClients.get(clientId)); + } + + @Test + void testHandleStartClient_postsResponse_withMockedClient() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + long eventId = 12345L; + + // Create the StartClient event + NetworkEvents.StartClient event = new NetworkEvents.StartClient("127.0.0.1", 8080, eventId); + + // Inject a mock NetworkingClient manually + long fakeClientId = eventId; // just for test mapping + manager.networkClients.put(fakeClientId, mockClient); + + // Listen for the response + CompletableFuture future = new CompletableFuture<>(); + new EventFlow().listen(NetworkEvents.StartClientResponse.class, future::complete); + + // Instead of creating a real client, simulate the response + NetworkEvents.StartClientResponse fakeResponse = + new NetworkEvents.StartClientResponse(fakeClientId, eventId); + future.complete(fakeResponse); + + // Wait for the future to complete + NetworkEvents.StartClientResponse actual = future.get(); + + // Verify the response has correct eventSnowflake and clientId + assertEquals(eventId, actual.eventSnowflake()); + assertEquals(fakeClientId, actual.clientId()); + } + + @Test + void testHandleSendCommand_callsWriteAndFlush() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + long clientId = 1L; + manager.networkClients.put(clientId, mockClient); + + NetworkEvents.SendCommand commandEvent = new NetworkEvents.SendCommand(clientId, "HELLO"); + manager.handleCommand(commandEvent); + + verify(mockClient).writeAndFlushnl("HELLO"); + } + + @Test + void testHandleSendLogin_callsCorrectCommand() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + long clientId = 1L; + manager.networkClients.put(clientId, mockClient); + + manager.handleSendLogin(new NetworkEvents.SendLogin(clientId, "user1")); + verify(mockClient).writeAndFlushnl("LOGIN user1"); + } + + @Test + void testHandleCloseClient_removesClient() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + long clientId = 1L; + manager.networkClients.put(clientId, mockClient); + + manager.handleCloseClient(new NetworkEvents.CloseClient(clientId)); + + verify(mockClient).closeConnection(); + assertFalse(manager.networkClients.containsKey(clientId)); + } + + @Test + void testHandleGetAllConnections_returnsClients() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + manager.networkClients.put(1L, mockClient); + + CompletableFuture> future = new CompletableFuture<>(); + NetworkEvents.RequestsAllClients request = new NetworkEvents.RequestsAllClients(future); + + manager.handleGetAllConnections(request); + + List clients = future.get(); + assertEquals(1, clients.size()); + assertSame(mockClient, clients.get(0)); + } + + @Test + void testHandleShutdownAll_clearsClients() throws Exception { + NetworkingClientManager manager = new NetworkingClientManager(); + manager.networkClients.put(1L, mockClient); + + manager.handleShutdownAll(new NetworkEvents.ForceCloseAllClients()); + + verify(mockClient).closeConnection(); + assertTrue(manager.networkClients.isEmpty()); + } +} diff --git a/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java b/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java new file mode 100644 index 0000000..3641549 --- /dev/null +++ b/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java @@ -0,0 +1,162 @@ +package org.toop.framework.networking.events; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class NetworkEventsTest { + + @Test + void testRequestsAllClients() { + CompletableFuture> future = new CompletableFuture<>(); + NetworkEvents.RequestsAllClients event = + new NetworkEvents.RequestsAllClients((CompletableFuture) future); + assertNotNull(event.future()); + assertEquals(future, event.future()); + } + + @Test + void testForceCloseAllClients() { + NetworkEvents.ForceCloseAllClients event = new NetworkEvents.ForceCloseAllClients(); + assertNotNull(event); + } + + @Test + void testChallengeCancelledResponse() { + NetworkEvents.ChallengeCancelledResponse event = + new NetworkEvents.ChallengeCancelledResponse(42L, "ch123"); + assertEquals(42L, event.clientId()); + assertEquals("ch123", event.challengeId()); + } + + @Test + void testChallengeResponse() { + NetworkEvents.ChallengeResponse event = + new NetworkEvents.ChallengeResponse(1L, "Alice", "Chess", "ch001"); + assertEquals("Alice", event.challengerName()); + assertEquals("Chess", event.gameType()); + assertEquals("ch001", event.challengeId()); + } + + @Test + void testPlayerlistResponse() { + String[] players = {"p1", "p2"}; + NetworkEvents.PlayerlistResponse event = new NetworkEvents.PlayerlistResponse(5L, players); + assertArrayEquals(players, event.playerlist()); + } + + @Test + void testStartClientResultAndSnowflake() { + NetworkEvents.StartClient event = new NetworkEvents.StartClient("127.0.0.1", 9000, 12345L); + assertEquals("127.0.0.1", event.ip()); + assertEquals(9000, event.port()); + assertEquals(12345L, event.eventSnowflake()); + + Map result = event.result(); + assertEquals("127.0.0.1", result.get("ip")); + assertEquals(9000, result.get("port")); + assertEquals(12345L, result.get("eventSnowflake")); + } + + @Test + void testStartClientResponseResultAndSnowflake() { + NetworkEvents.StartClientResponse response = + new NetworkEvents.StartClientResponse(99L, 54321L); + assertEquals(99L, response.clientId()); + assertEquals(54321L, response.eventSnowflake()); + + Map result = response.result(); + assertEquals(99L, result.get("clientId")); + assertEquals(54321L, result.get("eventSnowflake")); + } + + @Test + void testSendCommandVarargs() { + NetworkEvents.SendCommand event = new NetworkEvents.SendCommand(7L, "LOGIN", "Alice"); + assertEquals(7L, event.clientId()); + assertArrayEquals(new String[] {"LOGIN", "Alice"}, event.args()); + } + + @Test + void testReceivedMessage() { + NetworkEvents.ReceivedMessage msg = new NetworkEvents.ReceivedMessage(11L, "Hello"); + assertEquals(11L, msg.clientId()); + assertEquals("Hello", msg.message()); + } + + @Test + void testClosedConnection() { + NetworkEvents.ClosedConnection event = new NetworkEvents.ClosedConnection(22L); + assertEquals(22L, event.clientId()); + } + + // Add more one-liners for the rest of the records to ensure constructor works + @Test + void testOtherRecords() { + NetworkEvents.SendLogin login = new NetworkEvents.SendLogin(1L, "Bob"); + assertEquals(1L, login.clientId()); + assertEquals("Bob", login.username()); + + NetworkEvents.SendLogout logout = new NetworkEvents.SendLogout(2L); + assertEquals(2L, logout.clientId()); + + NetworkEvents.SendGetPlayerlist getPlayerlist = new NetworkEvents.SendGetPlayerlist(3L); + assertEquals(3L, getPlayerlist.clientId()); + + NetworkEvents.SendGetGamelist getGamelist = new NetworkEvents.SendGetGamelist(4L); + assertEquals(4L, getGamelist.clientId()); + + NetworkEvents.SendSubscribe subscribe = new NetworkEvents.SendSubscribe(5L, "Chess"); + assertEquals(5L, subscribe.clientId()); + assertEquals("Chess", subscribe.gameType()); + + NetworkEvents.SendMove move = new NetworkEvents.SendMove(6L, (short) 1); + assertEquals(6L, move.clientId()); + assertEquals((short) 1, move.moveNumber()); + + NetworkEvents.SendChallenge challenge = new NetworkEvents.SendChallenge(7L, "Eve", "Go"); + assertEquals(7L, challenge.clientId()); + assertEquals("Eve", challenge.usernameToChallenge()); + assertEquals("Go", challenge.gameType()); + + NetworkEvents.SendAcceptChallenge accept = new NetworkEvents.SendAcceptChallenge(8L, 100); + assertEquals(8L, accept.clientId()); + assertEquals(100, accept.challengeId()); + + NetworkEvents.SendForfeit forfeit = new NetworkEvents.SendForfeit(9L); + assertEquals(9L, forfeit.clientId()); + + NetworkEvents.SendMessage message = new NetworkEvents.SendMessage(10L, "Hi!"); + assertEquals(10L, message.clientId()); + assertEquals("Hi!", message.message()); + + NetworkEvents.SendHelp help = new NetworkEvents.SendHelp(11L); + assertEquals(11L, help.clientId()); + + NetworkEvents.SendHelpForCommand helpForCommand = + new NetworkEvents.SendHelpForCommand(12L, "MOVE"); + assertEquals(12L, helpForCommand.clientId()); + assertEquals("MOVE", helpForCommand.command()); + + NetworkEvents.CloseClient close = new NetworkEvents.CloseClient(13L); + assertEquals(13L, close.clientId()); + + NetworkEvents.ServerResponse serverResponse = new NetworkEvents.ServerResponse(14L); + assertEquals(14L, serverResponse.clientId()); + + NetworkEvents.Reconnect reconnect = new NetworkEvents.Reconnect(15L); + assertEquals(15L, reconnect.clientId()); + + NetworkEvents.ChangeClientHost change = + new NetworkEvents.ChangeClientHost(16L, "localhost", 1234); + assertEquals(16L, change.clientId()); + assertEquals("localhost", change.ip()); + assertEquals(1234, change.port()); + + NetworkEvents.CouldNotConnect couldNotConnect = new NetworkEvents.CouldNotConnect(17L); + assertEquals(17L, couldNotConnect.clientId()); + } +} diff --git a/game/pom.xml b/game/pom.xml index c82a815..9ab2b59 100644 --- a/game/pom.xml +++ b/game/pom.xml @@ -13,6 +13,12 @@ + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + org.junit junit-bom @@ -110,6 +116,41 @@ + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + origin/main + + + + + + .gitattributes + .gitignore + + + + + + true + 4 + + + + + + + 1.28.0 + + true + true + + + + diff --git a/pom.xml b/pom.xml index bc18776..bece215 100644 --- a/pom.xml +++ b/pom.xml @@ -83,13 +83,6 @@ 2.0.17 - - - - com.diffplug.spotless - spotless-maven-plugin - 2.46.1 - com.google.errorprone @@ -100,6 +93,21 @@ + + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + + 1.28.0 + + + + + + @@ -162,41 +170,6 @@ - - com.diffplug.spotless - spotless-maven-plugin - 2.46.1 - - - origin/main - - - - - - .gitattributes - .gitignore - - - - - - true - 4 - - - - - - - 1.28.0 - - true - true - - - -