Merge remote-tracking branch 'origin/UI' into UI

# Conflicts:
#	.idea/misc.xml
#	app/src/main/java/org/toop/app/menu/MainMenu.java
#	app/src/main/java/org/toop/app/menu/QuitMenu.java
This commit is contained in:
Ticho Hidding
2025-10-01 15:54:18 +02:00
65 changed files with 2542 additions and 961 deletions

View File

@@ -18,7 +18,7 @@ jobs:
fetch-depth: 0 # Fix for incremental formatting fetch-depth: 0 # Fix for incremental formatting
- uses: actions/setup-java@v5 - uses: actions/setup-java@v5
with: with:
java-version: '24' java-version: '25'
distribution: 'temurin' distribution: 'temurin'
cache: maven cache: maven
- name: Run Format Check - name: Run Format Check
@@ -30,12 +30,12 @@ jobs:
needs: formatting-check needs: formatting-check
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest] #windows-latest, macos-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-java@v5 - uses: actions/setup-java@v5
with: with:
java-version: '24' java-version: '25'
distribution: 'temurin' distribution: 'temurin'
cache: maven cache: maven
- name: Run Unittests - name: Run Unittests

8
.gitignore vendored
View File

@@ -94,3 +94,11 @@ nb-configuration.xml
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
##############################
## Hanze
##############################
newgamesver-release-V1.jar
server.properties
gameserver.log.*
gameserver.log

View File

@@ -2,8 +2,11 @@
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>aosp</w> <w>aosp</w>
<w>clid</w>
<w>dcompile</w> <w>dcompile</w>
<w>errorprone</w> <w>errorprone</w>
<w>flushnl</w>
<w>gaaf</w>
<w>gamelist</w> <w>gamelist</w>
<w>playerlist</w> <w>playerlist</w>
<w>tictactoe</w> <w>tictactoe</w>

View File

@@ -0,0 +1,8 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AutoCloseableResource" enabled="true" level="WARNING" enabled_by_default="true">
<option name="METHOD_MATCHER_CONFIG" value="java.util.Formatter,format,java.io.Writer,append,com.google.common.base.Preconditions,checkNotNull,org.hibernate.Session,close,java.io.PrintWriter,printf,java.io.PrintStream,printf,java.lang.foreign.Arena,ofAuto,java.lang.foreign.Arena,global,org.toop.framework.audio.AudioPlayer,play" />
</inspection_tool>
</profile>
</component>

View File

@@ -6,6 +6,7 @@
<version>0.1</version> <version>0.1</version>
<properties> <properties>
<main-class>org.toop.Main</main-class>
<maven.compiler.source>25</maven.compiler.source> <maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target> <maven.compiler.target>25</maven.compiler.target>
@@ -13,6 +14,12 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<dependency> <dependency>
<groupId>org.toop</groupId> <groupId>org.toop</groupId>
<artifactId>pism_framework</artifactId> <artifactId>pism_framework</artifactId>
@@ -46,6 +53,41 @@
<encoding>UTF-8</encoding> <encoding>UTF-8</encoding>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<!-- optional: limit format enforcement to just the files changed by this feature branch -->
<ratchetFrom>origin/main</ratchetFrom>
<formats>
<!-- you can define as many formats as you want, each is independent -->
<format>
<!-- define the files to apply to -->
<includes>
<include>.gitattributes</include>
<include>.gitignore</include>
</includes>
<!-- define the steps to apply to those files -->
<trimTrailingWhitespace/>
<endWithNewline/>
<indent>
<tabs>true</tabs>
<spacesPerTab>4</spacesPerTab>
</indent>
</format>
</formats>
<!-- define a language-specific format -->
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>AOSP</style> <!-- GOOGLE (2 indents), AOSP (4 indents) -->
<reflowLongStrings>true</reflowLongStrings>
<formatJavadoc>true</formatJavadoc>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@@ -1,6 +1,9 @@
package org.toop; package org.toop;
import org.toop.app.App; 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.NetworkingClientManager;
import org.toop.framework.networking.NetworkingInitializationException; import org.toop.framework.networking.NetworkingInitializationException;
@@ -11,6 +14,8 @@ public class Main {
} }
private static void initSystems() throws NetworkingInitializationException { 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();
} }
} }

View File

@@ -8,6 +8,9 @@ import org.toop.local.AppContext;
import java.util.Locale; import java.util.Locale;
import java.util.ResourceBundle; 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 { public final class MainMenu extends Menu {
private Locale currentLocale = AppContext.getLocale(); private Locale currentLocale = AppContext.getLocale();
@@ -22,17 +25,27 @@ public final class MainMenu extends Menu {
final Button battleship = createButton(resourceBundle.getString("mainMenuSelectBattleship"), () -> {}); final Button battleship = createButton(resourceBundle.getString("mainMenuSelectBattleship"), () -> {});
final Button other = createButton(resourceBundle.getString("mainMenuSelectOther"), () -> {}); 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); gamesBox.setAlignment(Pos.TOP_CENTER);
final Button credits = createButton(resourceBundle.getString("mainMenuSelectCredits"), () -> {}); final Button credits = createButton(resourceBundle.getString("mainMenuSelectCredits"), () -> {});
final Button options = createButton(resourceBundle.getString("mainMenuSelectOptions"), () -> {}); final Button options = createButton(resourceBundle.getString("mainMenuSelectOptions"), () -> {});
final Button quit = createButton(resourceBundle.getString("mainMenuSelectQuit"), () -> {}); 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); 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 = 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());
} }
} }

View File

@@ -8,6 +8,8 @@ import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.text.Text; import javafx.scene.text.Text;
import org.toop.app.App; import org.toop.app.App;
import org.toop.framework.asset.AssetManager;
import org.toop.framework.asset.resources.CssAsset;
import org.toop.local.AppContext; import org.toop.local.AppContext;
import java.util.Locale; import java.util.Locale;
@@ -49,6 +51,7 @@ public final class QuitMenu extends Menu {
StackPane.setAlignment(box, Pos.CENTER); StackPane.setAlignment(box, Pos.CENTER);
pane = modalContainer; pane = modalContainer;
pane.getStylesheets().add(getClass().getResource("/style/quit.css").toExternalForm()); CssAsset css = (CssAsset) AssetManager.getByName("quit.css").getResource();
pane.getStylesheets().add(css.getUrl());
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 956 B

After

Width:  |  Height:  |  Size: 956 B

View File

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

View File

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 15 MiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1 @@
Super gaaf!

View File

@@ -13,6 +13,12 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<dependency> <dependency>
<groupId>io.netty</groupId> <groupId>io.netty</groupId>
<artifactId>netty-all</artifactId> <artifactId>netty-all</artifactId>
@@ -90,6 +96,19 @@
<version>4.0.0</version> <version>4.0.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>25</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.reflections/reflections -->
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>0.10.2</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -123,6 +142,41 @@
<!-- <fork>true</fork>--> <!-- <fork>true</fork>-->
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<!-- optional: limit format enforcement to just the files changed by this feature branch -->
<ratchetFrom>origin/main</ratchetFrom>
<formats>
<!-- you can define as many formats as you want, each is independent -->
<format>
<!-- define the files to apply to -->
<includes>
<include>.gitattributes</include>
<include>.gitignore</include>
</includes>
<!-- define the steps to apply to those files -->
<trimTrailingWhitespace/>
<endWithNewline/>
<indent>
<tabs>true</tabs>
<spacesPerTab>4</spacesPerTab>
</indent>
</format>
</formats>
<!-- define a language-specific format -->
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>AOSP</style> <!-- GOOGLE (2 indents), AOSP (4 indents) -->
<reflowLongStrings>true</reflowLongStrings>
<formatJavadoc>true</formatJavadoc>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>

View File

@@ -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; import java.util.concurrent.atomic.AtomicLong;
public class SnowflakeGenerator { public class SnowflakeGenerator {
// Epoch start (choose your custom epoch to reduce bits wasted on old time) private static final long EPOCH = Instant.parse("2025-01-01T00:00:00Z").toEpochMilli();
private static final long EPOCH = 1700000000000L; // ~2023-11-15
// Bit allocations // Bit allocations
private static final long TIMESTAMP_BITS = 41; private static final long TIMESTAMP_BITS = 41;
@@ -14,20 +16,41 @@ public class SnowflakeGenerator {
// Max values // Max values
private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1; 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_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
private static final long MAX_TIMESTAMP = (1L << TIMESTAMP_BITS) - 1;
// Bit shifts // Bit shifts
private static final long MACHINE_SHIFT = SEQUENCE_BITS; private static final long MACHINE_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + MACHINE_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 final AtomicLong lastTimestamp = new AtomicLong(-1L);
private long sequence = 0L; private long sequence = 0L;
public SnowflakeGenerator(long machineId) { private static long genMachineId() {
if (machineId < 0 || machineId > MAX_MACHINE_ID) { try {
throw new IllegalArgumentException("Machine ID must be between 0 and " + MAX_MACHINE_ID); 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 (01023)
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() { public synchronized long nextId() {
@@ -37,6 +60,10 @@ public class SnowflakeGenerator {
throw new IllegalStateException("Clock moved backwards. Refusing to generate id."); 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()) { if (currentTimestamp == lastTimestamp.get()) {
sequence = (sequence + 1) & MAX_SEQUENCE; sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) { if (sequence == 0) {

View File

@@ -0,0 +1,29 @@
package org.toop.framework.asset;
import org.toop.framework.SnowflakeGenerator;
import org.toop.framework.asset.resources.BaseResource;
public class Asset <T extends BaseResource> {
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;
}
}

View File

@@ -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<Asset<? extends BaseResource>> assets = new CopyOnWriteArrayList<>();
private final Map<String, Function<File, ? extends BaseResource>> registry = new ConcurrentHashMap<>();
private volatile int loadedCount = 0;
private volatile int totalCount = 0;
public AssetLoader(File rootFolder) {
autoRegisterResources(); // make sure resources are registered!
List<File> 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<Asset<? extends BaseResource>> getAssets() {
return new ArrayList<>(this.assets);
}
public <T extends BaseResource> void register(String extension, Function<File, T> factory) {
this.registry.put(extension, factory);
}
private <T extends BaseResource> T resourceMapper(File file, Class<T> type) {
String ext = getExtension(file.getName());
Function<File, ? extends BaseResource> 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<File> files) {
for (File file : files) {
BaseResource resource = resourceMapper(file, BaseResource.class);
if (resource != null) {
Asset<? extends BaseResource> 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<File> 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<Class<? extends BaseResource>> classes = reflections.getSubTypesOf(BaseResource.class);
for (Class<? extends BaseResource> 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) : "";
}
}

View File

@@ -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<String, Asset<? extends BaseResource>> 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 <T extends BaseResource> ArrayList<Asset<T>> getAllOfType(Class<T> type) {
ArrayList<Asset<T>> list = new ArrayList<>();
for (Asset<? extends BaseResource> asset : assets.values()) { // <-- use .values()
if (type.isInstance(asset.getResource())) {
@SuppressWarnings("unchecked")
Asset<T> typed = (Asset<T>) asset;
list.add(typed);
}
}
return list;
}
public static Asset<? extends BaseResource> getById(String id) {
for (Asset<? extends BaseResource> asset : assets.values()) {
if (asset.getId().toString().equals(id)) {
return asset;
}
}
return null;
}
public static Asset<? extends BaseResource> getByName(String name) {
return assets.get(name);
}
public static Optional<Asset<? extends BaseResource>> findByName(String name) {
return Optional.ofNullable(assets.get(name));
}
public static void addAsset(Asset<? extends BaseResource> asset) {
assets.put(asset.getName(), asset);
}
}

View File

@@ -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 {}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package org.toop.framework.asset.resources;
import java.io.FileNotFoundException;
public interface LoadableResource {
void load();
void unload();
boolean isLoaded();
}

View File

@@ -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<Locale, ResourceBundle> 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<Locale> 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
}
}

View File

@@ -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;
}
}

View File

@@ -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<Long, Clip> activeClips = new HashMap<>();
private final HashMap<String, AudioAsset> 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<AudioAsset> 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> 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();
}
}

View File

@@ -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 {}
}

View File

@@ -1,24 +1,26 @@
package org.toop.framework.eventbus; 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.MethodHandle;
import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType; import java.lang.invoke.MethodType;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer; 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 * EventFlow is a utility class for creating, posting, and optionally subscribing to events in a
* in a type-safe and chainable manner. It is designed to work with the {@link GlobalEventBus}. * type-safe and chainable manner. It is designed to work with the {@link GlobalEventBus}.
* *
* <p>This class supports automatic UUID assignment for {@link EventWithSnowflake} events, * <p>This class supports automatic UUID assignment for {@link EventWithSnowflake} events, and
* and allows filtering subscribers so they only respond to events with a specific UUID. * allows filtering subscribers so they only respond to events with a specific UUID. All
* All subscription methods are chainable, and you can configure automatic unsubscription * subscription methods are chainable, and you can configure automatic unsubscription after an event
* after an event has been successfully handled.</p> * has been successfully handled.
*/ */
public class EventFlow { public class EventFlow {
@@ -35,10 +37,7 @@ public class EventFlow {
private EventType event = null; private EventType event = null;
/** The listener returned by GlobalEventBus subscription. Used for unsubscription. */ /** The listener returned by GlobalEventBus subscription. Used for unsubscription. */
private Object listener; private final List<ListenerHandler> listeners = new ArrayList<>();
/** Flag indicating whether to automatically unsubscribe the listener after success. */
private boolean unsubscribeAfterSuccess = false;
/** Holds the results returned from the subscribed event, if any. */ /** Holds the results returned from the subscribed event, if any. */
private Map<String, Object> result = null; private Map<String, Object> result = null;
@@ -46,28 +45,43 @@ public class EventFlow {
/** Empty constructor (event must be added via {@link #addPostEvent(Class, Object...)}). */ /** Empty constructor (event must be added via {@link #addPostEvent(Class, Object...)}). */
public EventFlow() {} public EventFlow() {}
/** // New: accept an event instance directly
* Instantiate an event of the given class and store it in this publisher. public EventFlow addPostEvent(EventType event) {
*/ this.event = event;
return this;
}
// Optional: accept a Supplier<EventType> to defer construction
public EventFlow addPostEvent(Supplier<? extends EventType> eventSupplier) {
this.event = eventSupplier.get();
return this;
}
// Keep the old class+args version if needed
public <T extends EventType> EventFlow addPostEvent(Class<T> eventClass, Object... args) { public <T extends EventType> EventFlow addPostEvent(Class<T> eventClass, Object... args) {
try { try {
boolean isUuidEvent = EventWithSnowflake.class.isAssignableFrom(eventClass); boolean isUuidEvent = EventWithSnowflake.class.isAssignableFrom(eventClass);
MethodHandle ctorHandle = CONSTRUCTOR_CACHE.computeIfAbsent(eventClass, cls -> { MethodHandle ctorHandle =
try { CONSTRUCTOR_CACHE.computeIfAbsent(
Class<?>[] paramTypes = cls.getDeclaredConstructors()[0].getParameterTypes(); eventClass,
MethodType mt = MethodType.methodType(void.class, paramTypes); cls -> {
return LOOKUP.findConstructor(cls, mt); try {
} catch (Exception e) { Class<?>[] paramTypes =
throw new RuntimeException("Failed to find constructor handle for " + cls, e); 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; Object[] finalArgs;
int expectedParamCount = ctorHandle.type().parameterCount(); int expectedParamCount = ctorHandle.type().parameterCount();
if (isUuidEvent && args.length < expectedParamCount) { if (isUuidEvent && args.length < expectedParamCount) {
this.eventSnowflake = new SnowflakeGenerator(1).nextId(); this.eventSnowflake = new SnowflakeGenerator().nextId();
finalArgs = new Object[args.length + 1]; finalArgs = new Object[args.length + 1];
System.arraycopy(args, 0, finalArgs, 0, args.length); System.arraycopy(args, 0, finalArgs, 0, args.length);
finalArgs[args.length] = this.eventSnowflake; finalArgs[args.length] = this.eventSnowflake;
@@ -86,124 +100,140 @@ public class EventFlow {
} }
} }
/** // public EventFlow addSnowflake() {
* Start listening for a response event type, chainable with perform(). // this.eventSnowflake = new SnowflakeGenerator(1).nextId();
*/ // return this;
public <TT extends EventType> ResponseBuilder<TT> onResponse(Class<TT> eventClass) { // }
return new ResponseBuilder<>(this, eventClass);
}
public static class ResponseBuilder<R extends EventType> { /** Subscribe by ID: only fires if UUID matches this publisher's eventId. */
private final EventFlow parent; public <TT extends EventWithSnowflake> EventFlow onResponse(
private final Class<R> responseClass; Class<TT> eventClass, Consumer<TT> 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<R> responseClass) { action.accept(event);
this.parent = parent;
this.responseClass = responseClass;
}
/** Finalize the subscription */ if (unsubscribeAfterSuccess && listenerHolder[0] != null) {
public EventFlow perform(Consumer<R> action) { GlobalEventBus.unsubscribe(listenerHolder[0]);
parent.listener = GlobalEventBus.subscribe(responseClass, event -> { this.listeners.remove(listenerHolder[0]);
action.accept(responseClass.cast(event)); }
if (parent.unsubscribeAfterSuccess && parent.listener != null) {
GlobalEventBus.unsubscribe(parent.listener);
}
});
return parent;
}
}
/** this.result = event.result();
* Subscribe by ID: only fires if UUID matches this publisher's eventId. }));
*/ this.listeners.add(listenerHolder[0]);
public <TT extends EventWithSnowflake> EventFlow onResponse(Class<TT> eventClass, Consumer<TT> 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();
}
});
return this; return this;
} }
/** /** Subscribe by ID: only fires if UUID matches this publisher's eventId. */
* Subscribe by ID without explicit class. public <TT extends EventWithSnowflake> EventFlow onResponse(
*/ Class<TT> eventClass, Consumer<TT> action) {
return this.onResponse(eventClass, action, true);
}
/** Subscribe by ID without explicit class. */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <TT extends EventWithSnowflake> EventFlow onResponse(Consumer<TT> action) { public <TT extends EventWithSnowflake> EventFlow onResponse(
this.listener = GlobalEventBus.subscribe(event -> { Consumer<TT> action, boolean unsubscribeAfterSuccess) {
if (event instanceof EventWithSnowflake uuidEvent) { ListenerHandler[] listenerHolder = new ListenerHandler[1];
if (uuidEvent.eventSnowflake() == this.eventSnowflake) { listenerHolder[0] =
try { new ListenerHandler(
TT typedEvent = (TT) uuidEvent; GlobalEventBus.subscribe(
action.accept(typedEvent); event -> {
if (unsubscribeAfterSuccess && listener != null) { if (!(event instanceof EventWithSnowflake uuidEvent)) return;
GlobalEventBus.unsubscribe(listener); if (uuidEvent.eventSnowflake() == this.eventSnowflake) {
} try {
this.result = typedEvent.result(); TT typedEvent = (TT) uuidEvent;
} catch (ClassCastException ignored) {} 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; return this;
} }
// choose event type public <TT extends EventWithSnowflake> EventFlow onResponse(Consumer<TT> action) {
public <TT extends EventType> EventSubscriberBuilder<TT> onEvent(Class<TT> eventClass) { return this.onResponse(action, true);
return new EventSubscriberBuilder<>(this, eventClass); }
public <TT extends EventType> EventFlow listen(
Class<TT> eventClass, Consumer<TT> 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 <TT extends EventType> EventFlow listen(Class<TT> eventClass, Consumer<TT> action) { public <TT extends EventType> EventFlow listen(Class<TT> eventClass, Consumer<TT> action) {
return this.onEvent(eventClass).perform(action); return this.listen(eventClass, action, true);
} }
// Builder for chaining .onEvent(...).perform(...) @SuppressWarnings("unchecked")
public static class EventSubscriberBuilder<TT extends EventType> { public <TT extends EventType> EventFlow listen(
private final EventFlow publisher; Consumer<TT> action, boolean unsubscribeAfterSuccess) {
private final Class<TT> eventClass; 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<TT> eventClass) { public <TT extends EventType> EventFlow listen(Consumer<TT> action) {
this.publisher = publisher; return this.listen(action, true);
this.eventClass = eventClass;
}
public EventFlow perform(Consumer<TT> action) {
publisher.listener = GlobalEventBus.subscribe(eventClass, event -> {
action.accept(eventClass.cast(event));
if (publisher.unsubscribeAfterSuccess && publisher.listener != null) {
GlobalEventBus.unsubscribe(publisher.listener);
}
});
return publisher;
}
} }
/** Post synchronously */ /** Post synchronously */
public EventFlow postEvent() { public EventFlow postEvent() {
GlobalEventBus.post(event); GlobalEventBus.post(this.event);
return this; return this;
} }
/** Post asynchronously */ /** Post asynchronously */
public EventFlow asyncPostEvent() { public EventFlow asyncPostEvent() {
GlobalEventBus.postAsync(event); GlobalEventBus.postAsync(this.event);
return this;
}
public EventFlow unsubscribeAfterSuccess() {
this.unsubscribeAfterSuccess = true;
return this;
}
public EventFlow unsubscribeNow() {
if (unsubscribeAfterSuccess && listener != null) {
GlobalEventBus.unsubscribe(listener);
}
return this; return this;
} }
@@ -215,7 +245,11 @@ public class EventFlow {
return event; return event;
} }
public long getEventId() { public ListenerHandler[] getListeners() {
return listeners.toArray(new ListenerHandler[0]);
}
public long getEventSnowflake() {
return eventSnowflake; return eventSnowflake;
} }
} }

View File

@@ -3,26 +3,26 @@ package org.toop.framework.eventbus;
import com.lmax.disruptor.*; import com.lmax.disruptor.*;
import com.lmax.disruptor.dsl.Disruptor; import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType; 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.Map;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.function.Consumer; 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, * GlobalEventBus backed by the LMAX Disruptor for ultra-low latency, high-throughput event
* high-throughput event publishing. * publishing.
*/ */
public final class GlobalEventBus { public final class GlobalEventBus {
/** Map of event class to type-specific listeners. */ /** Map of event class to type-specific listeners. */
private static final Map<Class<?>, CopyOnWriteArrayList<Consumer<? super EventType>>> LISTENERS = private static final Map<Class<?>, CopyOnWriteArrayList<Consumer<? super EventType>>>
new ConcurrentHashMap<>(); LISTENERS = new ConcurrentHashMap<>();
/** Map of event class to Snowflake-ID-specific listeners. */ /** Map of event class to Snowflake-ID-specific listeners. */
private static final Map<Class<?>, ConcurrentHashMap<Long, Consumer<? extends EventWithSnowflake>>> UUID_LISTENERS = private static final Map<
new ConcurrentHashMap<>(); Class<?>, ConcurrentHashMap<Long, Consumer<? extends EventWithSnowflake>>>
UUID_LISTENERS = new ConcurrentHashMap<>();
/** Disruptor ring buffer size (must be power of two). */ /** Disruptor ring buffer size (must be power of two). */
private static final int RING_BUFFER_SIZE = 1024 * 64; private static final int RING_BUFFER_SIZE = 1024 * 64;
@@ -34,27 +34,29 @@ public final class GlobalEventBus {
private static final RingBuffer<EventHolder> RING_BUFFER; private static final RingBuffer<EventHolder> RING_BUFFER;
static { static {
ThreadFactory threadFactory = r -> { ThreadFactory threadFactory =
Thread t = new Thread(r, "EventBus-Disruptor"); r -> {
t.setDaemon(true); Thread t = new Thread(r, "EventBus-Disruptor");
return t; t.setDaemon(true);
}; return t;
};
DISRUPTOR = new Disruptor<>( DISRUPTOR =
EventHolder::new, new Disruptor<>(
RING_BUFFER_SIZE, EventHolder::new,
threadFactory, RING_BUFFER_SIZE,
ProducerType.MULTI, threadFactory,
new BusySpinWaitStrategy() ProducerType.MULTI,
); new BusySpinWaitStrategy());
// Single consumer that dispatches to subscribers // Single consumer that dispatches to subscribers
DISRUPTOR.handleEventsWith((holder, seq, endOfBatch) -> { DISRUPTOR.handleEventsWith(
if (holder.event != null) { (holder, seq, endOfBatch) -> {
dispatchEvent(holder.event); if (holder.event != null) {
holder.event = null; dispatchEvent(holder.event);
} holder.event = null;
}); }
});
DISRUPTOR.start(); DISRUPTOR.start();
RING_BUFFER = DISRUPTOR.getRingBuffer(); RING_BUFFER = DISRUPTOR.getRingBuffer();
@@ -71,17 +73,21 @@ public final class GlobalEventBus {
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Subscription // Subscription
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
public static <T extends EventType> Consumer<T> subscribe(Class<T> eventClass, Consumer<T> listener) { public static <T extends EventType> Consumer<? super EventType> subscribe(
Class<T> eventClass, Consumer<T> listener) {
CopyOnWriteArrayList<Consumer<? super EventType>> list = CopyOnWriteArrayList<Consumer<? super EventType>> list =
LISTENERS.computeIfAbsent(eventClass, k -> new CopyOnWriteArrayList<>()); LISTENERS.computeIfAbsent(eventClass, k -> new CopyOnWriteArrayList<>());
list.add(event -> listener.accept(eventClass.cast(event)));
return listener; Consumer<? super EventType> wrapper = event -> listener.accept(eventClass.cast(event));
list.add(wrapper);
return wrapper;
} }
public static Consumer<Object> subscribe(Consumer<Object> listener) { public static Consumer<? super EventType> subscribe(Consumer<Object> listener) {
LISTENERS.computeIfAbsent(Object.class, _ -> new CopyOnWriteArrayList<>()) Consumer<? super EventType> wrapper = event -> listener.accept(event);
.add(listener); LISTENERS.computeIfAbsent(Object.class, _ -> new CopyOnWriteArrayList<>()).add(wrapper);
return listener; return wrapper;
} }
public static <T extends EventWithSnowflake> void subscribeById( public static <T extends EventWithSnowflake> void subscribeById(
@@ -95,7 +101,8 @@ public final class GlobalEventBus {
LISTENERS.values().forEach(list -> list.remove(listener)); LISTENERS.values().forEach(list -> list.remove(listener));
} }
public static <T extends EventWithSnowflake> void unsubscribeById(Class<T> eventClass, long eventId) { public static <T extends EventWithSnowflake> void unsubscribeById(
Class<T> eventClass, long eventId) {
Map<Long, Consumer<? extends EventWithSnowflake>> map = UUID_LISTENERS.get(eventClass); Map<Long, Consumer<? extends EventWithSnowflake>> map = UUID_LISTENERS.get(eventClass);
if (map != null) map.remove(eventId); if (map != null) map.remove(eventId);
} }
@@ -125,15 +132,22 @@ public final class GlobalEventBus {
CopyOnWriteArrayList<Consumer<? super EventType>> classListeners = LISTENERS.get(clazz); CopyOnWriteArrayList<Consumer<? super EventType>> classListeners = LISTENERS.get(clazz);
if (classListeners != null) { if (classListeners != null) {
for (Consumer<? super EventType> listener : classListeners) { for (Consumer<? super EventType> listener : classListeners) {
try { listener.accept(event); } catch (Throwable ignored) {} try {
listener.accept(event);
} catch (Throwable ignored) {
}
} }
} }
// generic listeners // generic listeners
CopyOnWriteArrayList<Consumer<? super EventType>> genericListeners = LISTENERS.get(Object.class); CopyOnWriteArrayList<Consumer<? super EventType>> genericListeners =
LISTENERS.get(Object.class);
if (genericListeners != null) { if (genericListeners != null) {
for (Consumer<? super EventType> listener : genericListeners) { for (Consumer<? super EventType> 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<EventWithSnowflake> listener = Consumer<EventWithSnowflake> listener =
(Consumer<EventWithSnowflake>) map.remove(snowflakeEvent.eventSnowflake()); (Consumer<EventWithSnowflake>) map.remove(snowflakeEvent.eventSnowflake());
if (listener != null) { if (listener != null) {
try { listener.accept(snowflakeEvent); } catch (Throwable ignored) {} try {
listener.accept(snowflakeEvent);
} catch (Throwable ignored) {
}
} }
} }
} }

View File

@@ -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;
// }
}

View File

@@ -7,53 +7,72 @@ import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder; import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.toop.framework.eventbus.EventFlow;
import java.util.function.Supplier; import org.toop.framework.networking.events.NetworkEvents;
public class NetworkingClient { public class NetworkingClient {
private static final Logger logger = LogManager.getLogger(NetworkingClient.class); private static final Logger logger = LogManager.getLogger(NetworkingClient.class);
final Bootstrap bootstrap = new Bootstrap(); private long connectionId;
final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); private String host;
private int port;
private String connectionUuid;
private Channel channel; private Channel channel;
private NetworkingGameClientHandler handler; private NetworkingGameClientHandler handler;
public NetworkingClient( public NetworkingClient(
Supplier<? extends NetworkingGameClientHandler> handlerFactory, Supplier<NetworkingGameClientHandler> handlerFactory,
String host, String host,
int port) { int port,
long connectionId) {
this.connectionId = connectionId;
try { try {
this.bootstrap.group(this.workerGroup); Bootstrap bootstrap = new Bootstrap();
this.bootstrap.channel(NioSocketChannel.class); EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
this.bootstrap.option(ChannelOption.SO_KEEPALIVE, true); bootstrap.group(workerGroup);
this.bootstrap.handler(new ChannelInitializer<SocketChannel>() { bootstrap.channel(NioSocketChannel.class);
@Override bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
public void initChannel(SocketChannel ch) { bootstrap.handler(
handler = handlerFactory.get(); new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
handler = handlerFactory.get();
ChannelPipeline pipeline = ch.pipeline(); ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new LineBasedFrameDecoder(1024)); // split at \n pipeline.addLast(new LineBasedFrameDecoder(1024)); // split at \n
pipeline.addLast(new StringDecoder()); // bytes -> String pipeline.addLast(
pipeline.addLast(handler); new StringDecoder(CharsetUtil.UTF_8)); // bytes -> String
} pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8));
}); pipeline.addLast(handler);
ChannelFuture channelFuture = this.bootstrap.connect(host, port).sync(); }
});
ChannelFuture channelFuture = bootstrap.connect(host, port).sync();
this.channel = channelFuture.channel(); this.channel = channelFuture.channel();
this.host = host;
this.port = port;
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to create networking client instance", e); logger.error("Failed to create networking client instance", e);
} }
} }
public NetworkingGameClientHandler getHandler() { public NetworkingGameClientHandler getHandler() {
return handler; return this.handler;
} }
public void setConnectionUuid(String connectionUuid) { public String getHost() {
this.connectionUuid = connectionUuid; return this.host;
}
public int getPort() {
return this.port;
}
public void setConnectionId(long connectionId) {
this.connectionId = connectionId;
} }
public boolean isChannelActive() { public boolean isChannelActive() {
@@ -64,77 +83,48 @@ public class NetworkingClient {
String literalMsg = msg.replace("\n", "\\n").replace("\r", "\\r"); String literalMsg = msg.replace("\n", "\\n").replace("\r", "\\r");
if (isChannelActive()) { if (isChannelActive()) {
this.channel.writeAndFlush(msg); this.channel.writeAndFlush(msg);
logger.info("Connection {} sent message: {}", this.channel.remoteAddress(), literalMsg); logger.info(
"Connection {} sent message: '{}'", this.channel.remoteAddress(), literalMsg);
} else { } else {
logger.warn("Cannot send message: {}, connection inactive.", literalMsg); logger.warn("Cannot send message: '{}', connection inactive.", literalMsg);
} }
} }
public void writeAndFlushnl(String msg) { public void writeAndFlushnl(String msg) {
if (isChannelActive()) { if (isChannelActive()) {
this.channel.writeAndFlush(msg + "\n"); this.channel.writeAndFlush(msg + "\r\n");
logger.info("Connection {} sent message: {}", this.channel.remoteAddress(), msg); logger.info("Connection {} sent message: '{}'", this.channel.remoteAddress(), msg);
} else { } 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() { public void closeConnection() {
if (this.channel != null && this.channel.isActive()) { if (this.channel != null && this.channel.isActive()) {
this.channel.close().addListener(future -> { this.channel
if (future.isSuccess()) { .close()
logger.info("Connection {} closed successfully", this.channel.remoteAddress()); .addListener(
} else { future -> {
logger.error("Error closing connection {}. Error: {}", if (future.isSuccess()) {
this.channel.remoteAddress(), logger.info(
future.cause().getMessage()); "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;
}
} }

View File

@@ -2,10 +2,9 @@ package org.toop.framework.networking;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.toop.framework.SnowflakeGenerator;
import org.toop.framework.eventbus.EventFlow; import org.toop.framework.eventbus.EventFlow;
import org.toop.framework.networking.events.NetworkEvents; import org.toop.framework.networking.events.NetworkEvents;
@@ -14,111 +13,189 @@ public class NetworkingClientManager {
private static final Logger logger = LogManager.getLogger(NetworkingClientManager.class); private static final Logger logger = LogManager.getLogger(NetworkingClientManager.class);
/** Map of serverId -> Server instances */ /** Map of serverId -> Server instances */
private final Map<String, NetworkingClient> networkClients = new ConcurrentHashMap<>(); final Map<Long, NetworkingClient> networkClients = new ConcurrentHashMap<>();
/** Starts a connection manager, to manage, connections. */ /** Starts a connection manager, to manage, connections. */
public NetworkingClientManager() throws NetworkingInitializationException { public NetworkingClientManager() throws NetworkingInitializationException {
try { try {
new EventFlow().listen(NetworkEvents.StartClientRequest.class, this::handleStartClientRequest); new EventFlow()
new EventFlow().listen(NetworkEvents.StartClient.class, this::handleStartClient); .listen(this::handleStartClient)
new EventFlow().listen(NetworkEvents.SendCommand.class, this::handleCommand); .listen(this::handleCommand)
new EventFlow().listen(NetworkEvents.CloseClient.class, this::handleCloseClient); .listen(this::handleSendLogin)
new EventFlow().listen(NetworkEvents.RequestsAllClients.class, this::getAllConnections); .listen(this::handleSendLogout)
new EventFlow().listen(NetworkEvents.ForceCloseAllClients.class, this::shutdownAll); .listen(this::handleSendGetPlayerlist)
} catch (Exception e) { .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); logger.error("Failed to initialize the client manager", e);
throw e; throw e;
} }
} }
private String startClientRequest(Supplier<? extends NetworkingGameClientHandler> handlerFactory, long startClientRequest(String ip, int port) {
String ip, long connectionId = new SnowflakeGenerator().nextId(); // TODO: Maybe use the one generated
int port) { try { // With EventFlow
String connectionUuid = UUID.randomUUID().toString(); NetworkingClient client =
try { new NetworkingClient(
NetworkingClient client = new NetworkingClient( () -> new NetworkingGameClientHandler(connectionId),
handlerFactory, ip,
ip, port,
port); connectionId);
this.networkClients.put(connectionUuid, client); client.setConnectionId(connectionId);
this.networkClients.put(connectionId, client);
logger.info("New client started successfully for {}:{}", ip, port);
} catch (Exception e) { } catch (Exception e) {
logger.error(e); logger.error(e);
} }
logger.info("Client {} started", connectionUuid); return connectionId;
return connectionUuid;
} }
private void handleStartClientRequest(NetworkEvents.StartClientRequest request) { private long startClientRequest(String ip, int port, long clientId) {
request.future() try { // With EventFlow
.complete( NetworkingClient client =
this.startClientRequest( new NetworkingClient(
request.handlerFactory(), () -> new NetworkingGameClientHandler(clientId), ip, port, clientId);
request.ip(), client.setConnectionId(clientId);
request.port())); // TODO: Maybe post ConnectionEstablished event. 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) { void handleStartClient(NetworkEvents.StartClient event) {
String uuid = this.startClientRequest(event.handlerFactory(), event.ip(), event.port()); long id = this.startClientRequest(event.ip(), event.port());
new EventFlow().addPostEvent(NetworkEvents.StartClientSuccess.class, new Thread(
uuid, event.eventSnowflake() () -> {
).asyncPostEvent(); 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 NetworkEvents.SendCommand
event) { // TODO: Move this to ServerConnection class, keep it internal. event) { // TODO: Move this to ServerConnection class, keep it internal.
NetworkingClient client = this.networkClients.get(event.connectionId()); NetworkingClient client = this.networkClients.get(event.clientId());
logger.info("Preparing to send command: {} to server: {}", event.args(), client); String args = String.join(" ", event.args());
if (client != null) { sendCommand(client, args);
String args = String.join(" ", event.args()) + "\n";
client.writeAndFlush(args);
} else {
logger.warn("Server {} not found for command '{}'", event.connectionId(), event.args());
}
} }
private void handleCloseClient(NetworkEvents.CloseClient event) { void handleSendLogin(NetworkEvents.SendLogin event) {
NetworkingClient client = this.networkClients.get(event.connectionId()); 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? client.closeConnection(); // TODO: Check if not blocking, what if error, mb not remove?
this.networkClients.remove(event.connectionId()); this.networkClients.remove(event.clientId());
logger.info("Client {} closed successfully.", event.connectionId()); logger.info("Client {} closed successfully.", event.clientId());
} }
// private void handleReconnect(Events.ServerEvents.Reconnect event) { void handleGetAllConnections(NetworkEvents.RequestsAllClients request) {
// 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) {
List<NetworkingClient> a = new ArrayList<>(this.networkClients.values()); List<NetworkingClient> 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.values().forEach(NetworkingClient::closeConnection);
this.networkClients.clear(); this.networkClients.clear();
logger.info("All servers shut down"); logger.info("All servers shut down");

View File

@@ -2,19 +2,224 @@ package org.toop.framework.networking;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.toop.framework.eventbus.EventFlow;
import org.toop.framework.networking.events.NetworkEvents;
public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter { public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter {
private static final Logger logger = LogManager.getLogger(NetworkingGameClientHandler.class); private static final Logger logger = LogManager.getLogger(NetworkingGameClientHandler.class);
public NetworkingGameClientHandler() {} private final long connectionId;
public NetworkingGameClientHandler(long connectionId) {
this.connectionId = connectionId;
}
@Override @Override
public void channelRead(ChannelHandlerContext ctx, Object msg) { 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 @Override
@@ -22,5 +227,4 @@ public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter {
logger.error(cause.getMessage(), cause); logger.error(cause.getMessage(), cause);
ctx.close(); ctx.close();
} }
} }

View File

@@ -1,93 +1,143 @@
package org.toop.framework.networking.events; 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.lang.reflect.RecordComponent;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; 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}.
*
* <p>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 { public class NetworkEvents extends EventsBase {
/** /**
* BLOCKING Requests all active connections. The result is returned via the provided * Requests all active client connections.
* CompletableFuture.
* *
* @param future List of all connections in string form. * <p>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<String> future) implements EventWithoutSnowflake {} public record RequestsAllClients(CompletableFuture<List<NetworkingClient>> future)
implements EventWithoutSnowflake {}
/** Forces closing all active connections immediately. */ /** Forces all active client connections to close immediately. */
public record ForceCloseAllClients() implements EventWithoutSnowflake {} public record ForceCloseAllClients() implements EventWithoutSnowflake {}
public record CloseClientRequest(CompletableFuture<String> 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. * Event to start a new client connection.
* <p>
* 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:
* <br>
* - A factory for creating the Netty handler that will manage the connection
* <br>
* - The server's IP address and port
* <br>
* - A unique event identifier for correlation with follow-up events
* </p>
* *
* <p> * <p>Carries IP, port, and a unique event ID for correlation with responses.
* 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}.
* </p>
* *
* @param handlerFactory Factory for constructing a {@link NetworkingGameClientHandler}. * @param ip Server IP address.
* @param ip The IP address of the server to connect to. * @param port Server port.
* @param port The port number of the server to connect to. * @param eventSnowflake Unique event identifier for correlation.
* @param eventSnowflake A unique identifier for this event, typically injected
* automatically by the {@link org.toop.framework.eventbus.EventFlow}.
*/ */
public record StartClient( public record StartClient(String ip, int port, long eventSnowflake)
Supplier<? extends NetworkingGameClientHandler> handlerFactory, implements EventWithSnowflake {
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<String, Object>} containing field names and values
*/
@Override @Override
public Map<String, Object> result() { public Map<String, Object> result() {
return Stream.of(this.getClass().getRecordComponents()) return Stream.of(this.getClass().getRecordComponents())
.collect(Collectors.toMap( .collect(
RecordComponent::getName, Collectors.toMap(
rc -> { RecordComponent::getName,
try { rc -> {
return rc.getAccessor().invoke(this); try {
} catch (Exception e) { return rc.getAccessor().invoke(this);
throw new RuntimeException(e); } catch (Exception e) {
} throw new RuntimeException(e);
} }
)); }));
} }
/**
* Returns the unique event identifier used for correlating this event.
*
* @return the event ID string
*/
@Override @Override
public long eventSnowflake() { public long eventSnowflake() {
return this.eventSnowflake; return this.eventSnowflake;
@@ -95,37 +145,26 @@ public class NetworkEvents extends EventsBase {
} }
/** /**
* TODO: Update docs new input. * Response confirming a client was started.
* BLOCKING Triggers starting a server connection and returns a future.
* *
* @param ip The IP address of the server to connect to. * @param clientId The client ID assigned to the new connection.
* @param port The port of the server to connect to. * @param eventSnowflake Event ID used for correlation.
* @param future Returns the UUID of the connection, when connection is established.
*/ */
public record StartClientRequest( public record StartClientResponse(long clientId, long eventSnowflake)
Supplier<? extends NetworkingGameClientHandler> handlerFactory,
String ip, int port, CompletableFuture<String> 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)
implements EventWithSnowflake { implements EventWithSnowflake {
@Override @Override
public Map<String, Object> result() { public Map<String, Object> result() {
return Stream.of(this.getClass().getRecordComponents()) return Stream.of(this.getClass().getRecordComponents())
.collect(Collectors.toMap( .collect(
RecordComponent::getName, Collectors.toMap(
rc -> { RecordComponent::getName,
try { rc -> {
return rc.getAccessor().invoke(this); try {
} catch (Exception e) { return rc.getAccessor().invoke(this);
throw new RuntimeException(e); } catch (Exception e) {
} throw new RuntimeException(e);
} }
)); }));
} }
@Override @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. * @param args The command arguments.
*/ */
public record SendCommand(String connectionId, String... args) implements EventWithoutSnowflake {} public record SendCommand(long clientId, 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 {}
/** 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 clientId The connection ID that received the message.
* @param message The message received. * @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 clientId The client connection ID.
* @param ip The new IP address. * @param ip The new server IP.
* @param port The new port. * @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 {}
/** /** Event indicating a client connection was closed. */
* Triggers when the server couldn't connect to the desired address. public record ClosedConnection(long clientId) implements EventWithoutSnowflake {}
*
* @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 {}
} }

View File

@@ -1,4 +1,4 @@
package org.toop; package org.toop.framework;
import static org.junit.jupiter.api.Assertions.*; 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.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.toop.framework.Logging;
public class LoggingTest { public class LoggingTest {
@@ -106,6 +105,6 @@ public class LoggingTest {
LoggerConfig loggerConfig = LoggerConfig loggerConfig =
ctx.getConfiguration().getLoggers().get("org.toop.DoesNotExist"); ctx.getConfiguration().getLoggers().get("org.toop.DoesNotExist");
assertNull(loggerConfig); // class doesn't exist, so no logger added assertNull(loggerConfig);
} }
} }

View File

@@ -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<Long> 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);
}
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<HeavyEvent> 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");
// }
// }

View File

@@ -1,30 +1,31 @@
package org.toop.framework.eventbus; package org.toop.framework.eventbus;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*;
import org.toop.framework.eventbus.events.EventWithSnowflake;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import org.toop.framework.SnowflakeGenerator;
import org.toop.framework.eventbus.events.EventWithSnowflake;
class EventFlowTest { class EventFlowTest {
@Test @Test
void testSnowflakeStructure() { void testSnowflakeStructure() {
long id = new SnowflakeGenerator(1).nextId(); long id = new SnowflakeGenerator().nextId();
long timestampPart = id >>> 22; long timestampPart = id >>> 22;
long randomPart = id & ((1L << 22) - 1); long randomPart = id & ((1L << 22) - 1);
assertTrue(timestampPart > 0, "Timestamp part should be non-zero"); 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 @Test
void testSnowflakeMonotonicity() throws InterruptedException { void testSnowflakeMonotonicity() throws InterruptedException {
SnowflakeGenerator sf = new SnowflakeGenerator(1); SnowflakeGenerator sf = new SnowflakeGenerator();
long id1 = sf.nextId(); long id1 = sf.nextId();
Thread.sleep(1); // ensure timestamp increases Thread.sleep(1); // ensure timestamp increases
long id2 = sf.nextId(); long id2 = sf.nextId();
@@ -34,7 +35,7 @@ class EventFlowTest {
@Test @Test
void testSnowflakeUniqueness() { void testSnowflakeUniqueness() {
SnowflakeGenerator sf = new SnowflakeGenerator(1); SnowflakeGenerator sf = new SnowflakeGenerator();
Set<Long> ids = new HashSet<>(); Set<Long> ids = new HashSet<>();
for (int i = 0; i < 100_000; i++) { for (int i = 0; i < 100_000; i++) {
long id = sf.nextId(); long id = sf.nextId();
@@ -45,9 +46,20 @@ class EventFlowTest {
// --- Dummy Event classes for testing --- // --- Dummy Event classes for testing ---
static class DummySnowflakeEvent implements EventWithSnowflake { static class DummySnowflakeEvent implements EventWithSnowflake {
private final long snowflake; private final long snowflake;
DummySnowflakeEvent(long snowflake) { this.snowflake = snowflake; }
@Override public long eventSnowflake() { return snowflake; } DummySnowflakeEvent(long snowflake) {
@Override public java.util.Map<String, Object> result() { return java.util.Collections.emptyMap(); } this.snowflake = snowflake;
}
@Override
public long eventSnowflake() {
return snowflake;
}
@Override
public java.util.Map<String, Object> result() {
return java.util.Collections.emptyMap();
}
} }
@Test @Test
@@ -55,7 +67,7 @@ class EventFlowTest {
EventFlow flow = new EventFlow(); EventFlow flow = new EventFlow();
flow.addPostEvent(DummySnowflakeEvent.class); // no args, should auto-generate 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"); assertNotEquals(-1, id, "Snowflake should be auto-generated");
assertTrue(flow.getEvent() instanceof DummySnowflakeEvent); assertTrue(flow.getEvent() instanceof DummySnowflakeEvent);
assertEquals(id, ((DummySnowflakeEvent) flow.getEvent()).eventSnowflake()); assertEquals(id, ((DummySnowflakeEvent) flow.getEvent()).eventSnowflake());
@@ -74,7 +86,7 @@ class EventFlowTest {
assertFalse(handlerCalled.get(), "Handler should not fire for mismatched snowflake"); assertFalse(handlerCalled.get(), "Handler should not fire for mismatched snowflake");
// Post with matching 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"); assertTrue(handlerCalled.get(), "Handler should fire for matching snowflake");
} }
} }

View File

@@ -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<String, Object> 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");
// }
//}

View File

@@ -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<String, Object> 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<String, Object> 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<HeavyEvent> 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");
}
}

View File

@@ -1,110 +1,159 @@
//package org.toop.framework.eventbus; 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<String> 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<String> 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()");
// }
// @Test import static org.junit.jupiter.api.Assertions.*;
// void testSetReplacesBus() {
// MBassadorMock<IEvent> mockBus = new MBassadorMock<>(); import java.util.concurrent.*;
// GlobalEventBus.set(mockBus); import java.util.concurrent.atomic.AtomicBoolean;
// import java.util.concurrent.atomic.AtomicReference;
// TestEvent event = new TestEvent("test"); import java.util.function.Consumer;
// GlobalEventBus.post(event); import org.junit.jupiter.api.*;
// import org.toop.framework.eventbus.events.EventType;
// assertEquals(event, mockBus.lastPosted, "Custom bus should receive the event"); import org.toop.framework.eventbus.events.EventWithSnowflake;
// }
// class GlobalEventBusTest {
// // Minimal fake MBassador for verifying set()
// static class MBassadorMock<T extends IEvent> extends net.engio.mbassy.bus.MBassador<T> { // ------------------------------------------------------------------------
// T lastPosted; // Test Events
// // ------------------------------------------------------------------------
// @Override private record TestEvent(String message) implements EventType {}
// public SyncAsyncPostCommand<T> post(T message) {
// this.lastPosted = message; private record TestSnowflakeEvent(long eventSnowflake, String payload)
// return super.post(message); implements EventWithSnowflake {
// } @Override
// } public java.util.Map<String, Object> 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<String> received = new AtomicReference<>();
Consumer<TestEvent> 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<? super EventType> 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<EventType> received = new AtomicReference<>();
Consumer<Object> 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<String> 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);
}
}

View File

@@ -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<NetworkEvents.StartClientResponse> 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<List<NetworkingClient>> future = new CompletableFuture<>();
NetworkEvents.RequestsAllClients request = new NetworkEvents.RequestsAllClients(future);
manager.handleGetAllConnections(request);
List<NetworkingClient> 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());
}
}

View File

@@ -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<List<String>> 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<String, Object> 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<String, Object> 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());
}
}

View File

@@ -13,6 +13,12 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<dependency> <dependency>
<groupId>org.junit</groupId> <groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId> <artifactId>junit-bom</artifactId>
@@ -110,6 +116,41 @@
<!-- <fork>true</fork>--> <!-- <fork>true</fork>-->
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<!-- optional: limit format enforcement to just the files changed by this feature branch -->
<ratchetFrom>origin/main</ratchetFrom>
<formats>
<!-- you can define as many formats as you want, each is independent -->
<format>
<!-- define the files to apply to -->
<includes>
<include>.gitattributes</include>
<include>.gitignore</include>
</includes>
<!-- define the steps to apply to those files -->
<trimTrailingWhitespace/>
<endWithNewline/>
<indent>
<tabs>true</tabs>
<spacesPerTab>4</spacesPerTab>
</indent>
</format>
</formats>
<!-- define a language-specific format -->
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>AOSP</style> <!-- GOOGLE (2 indents), AOSP (4 indents) -->
<reflowLongStrings>true</reflowLongStrings>
<formatJavadoc>true</formatJavadoc>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>

57
pom.xml
View File

@@ -83,13 +83,6 @@
<version>2.0.17</version> <version>2.0.17</version>
</dependency> </dependency>
<!-- https://mvnrepository.com/artifact/com.diffplug.spotless/spotless-maven-plugin -->
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.errorprone/error_prone_annotations --> <!-- https://mvnrepository.com/artifact/com.google.errorprone/error_prone_annotations -->
<dependency> <dependency>
<groupId>com.google.errorprone</groupId> <groupId>com.google.errorprone</groupId>
@@ -100,6 +93,21 @@
</dependencyManagement> </dependencyManagement>
<build> <build>
<plugins>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>AOSP</style>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins>
<pluginManagement> <pluginManagement>
<plugins> <plugins>
<plugin> <plugin>
@@ -162,41 +170,6 @@
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<!-- optional: limit format enforcement to just the files changed by this feature branch -->
<ratchetFrom>origin/main</ratchetFrom>
<formats>
<!-- you can define as many formats as you want, each is independent -->
<format>
<!-- define the files to apply to -->
<includes>
<include>.gitattributes</include>
<include>.gitignore</include>
</includes>
<!-- define the steps to apply to those files -->
<trimTrailingWhitespace/>
<endWithNewline/>
<indent>
<tabs>true</tabs>
<spacesPerTab>4</spacesPerTab>
</indent>
</format>
</formats>
<!-- define a language-specific format -->
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>AOSP</style> <!-- GOOGLE (2 indents), AOSP (4 indents) -->
<reflowLongStrings>true</reflowLongStrings>
<formatJavadoc>true</formatJavadoc>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins> </plugins>
</pluginManagement> </pluginManagement>
</build> </build>