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

# Conflicts:
#	app/src/main/java/org/toop/app/App.java
#	app/src/main/java/org/toop/app/menu/MainMenu.java
#	app/src/main/java/org/toop/app/menu/Menu.java
#	app/src/main/java/org/toop/app/menu/QuitMenu.java
#	app/src/main/resources/assets/image/background.jpg
#	app/src/main/resources/assets/image/battleship.png
#	app/src/main/resources/assets/image/icon.png
#	app/src/main/resources/assets/image/lowpoly.png
#	app/src/main/resources/assets/image/other.png
#	app/src/main/resources/assets/image/reversi.png
#	app/src/main/resources/assets/image/sudoku.png
#	app/src/main/resources/assets/image/tictactoe.png
#	app/src/main/resources/assets/style/app.css
#	app/src/main/resources/assets/style/main.css
#	app/src/main/resources/assets/style/quit.css
#	app/src/main/resources/assets/style/style.css
This commit is contained in:
ramollia
2025-10-02 19:47:09 +02:00
78 changed files with 3437 additions and 1098 deletions

View File

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

8
.gitignore vendored
View File

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

1
.idea/compiler.xml generated
View File

@@ -7,7 +7,6 @@
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="pism_framework" />
<module name="pis" />
<module name="pism_game" />
<module name="pism_app" />
</profile>

View File

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

2
.idea/misc.xml generated
View File

@@ -13,7 +13,7 @@
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" project-jdk-name="openjdk-25" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="openjdk-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

15
.idea/resourceBundles.xml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ResourceBundleManager">
<custom-resource-bundle>
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization.properties" />
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization_de.properties" />
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization_es.properties" />
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization_fr.properties" />
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization_it.properties" />
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization_nl.properties" />
<file value="file://$PROJECT_DIR$/app/src/main/resources/assets/localization/Localization_zh.properties" />
<base-name>localization</base-name>
</custom-resource-bundle>
</component>
</project>

View File

@@ -6,6 +6,7 @@
<version>0.1</version>
<properties>
<main-class>org.toop.Main</main-class>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
@@ -13,6 +14,12 @@
</properties>
<dependencies>
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<dependency>
<groupId>org.toop</groupId>
<artifactId>pism_framework</artifactId>
@@ -46,6 +53,41 @@
<encoding>UTF-8</encoding>
</configuration>
</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>
</build>
</project>

View File

@@ -1,6 +1,9 @@
package org.toop;
import org.toop.app.App;
import org.toop.framework.asset.AssetLoader;
import org.toop.framework.asset.AssetManager;
import org.toop.framework.audio.SoundManager;
import org.toop.framework.networking.NetworkingClientManager;
import org.toop.framework.networking.NetworkingInitializationException;
@@ -11,6 +14,8 @@ public final class Main {
}
private static void initSystems() throws NetworkingInitializationException {
new NetworkingClientManager();
AssetManager.loadAssets(new AssetLoader("app/src/main/resources/assets"));
new Thread(NetworkingClientManager::new).start();
new Thread(SoundManager::new).start();
}
}

View File

@@ -1,6 +1,15 @@
package org.toop.app.menu;
import org.toop.framework.asset.AssetManager;
import org.toop.framework.asset.resources.LocalizationAsset;
import org.toop.local.AppContext;
import java.util.Locale;
import java.util.ResourceBundle;
public final class CreditsMenu extends Menu {
private Locale currentLocale = AppContext.getLocale();
private LocalizationAsset loc = AssetManager.get("localization.properties");
public CreditsMenu() {
}
}

View File

@@ -1,6 +1,15 @@
package org.toop.app.menu;
import org.toop.framework.asset.AssetManager;
import org.toop.framework.asset.resources.LocalizationAsset;
import org.toop.local.AppContext;
import java.util.Locale;
import java.util.ResourceBundle;
public final class OptionsMenu extends Menu {
private Locale currentLocale = AppContext.getLocale();
private LocalizationAsset loc = AssetManager.get("localization.properties");
public OptionsMenu() {
}
}

View File

@@ -0,0 +1,14 @@
package org.toop.local;
import java.util.Locale;
public class AppContext {
private static Locale currentLocale = Locale.getDefault();
public static void setCurrentLocale(Locale locale) {
currentLocale = locale;
}
public static Locale getLocale() {
return currentLocale;
}
}

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 357 KiB

View File

@@ -0,0 +1,17 @@
# Window title
windowTitle=ISY Games Selector
# Main Menu buttons
mainMenuSelectTicTacToe=Tic Tac Toe\u5426
mainMenuSelectReversi=Reversi\u5426
mainMenuSelectSudoku=Sudoku
mainMenuSelectBattleship=Battleship
mainMenuSelectOther=Other
mainMenuSelectCredits=Credits
mainMenuSelectOptions=Options
mainMenuSelectQuit=Quit
# Quit Menu text and buttons
quitMenuTextSure=Are you sure?
quitMenuButtonYes=Yes
quitMenuButtonNo=No

View File

@@ -0,0 +1,17 @@
# Window title
windowTitle=ISY Spiele-Auswahl
# Main Menu buttons
mainMenuSelectTicTacToe=Tic Tac Toe
mainMenuSelectReversi=Reversi
mainMenuSelectSudoku=Sudoku
mainMenuSelectBattleship=Flottenman\u00F6ver
mainMenuSelectOther=Andere
mainMenuSelectCredits=Credits
mainMenuSelectOptions=Optionen
mainMenuSelectQuit=Beenden
# Quit Menu text and buttons
quitMenuTextSure=Bist du sicher?
quitMenuButtonYes=Ja
quitMenuButtonNo=Nein

View File

@@ -0,0 +1,17 @@
# Window title
windowTitle=Selector de juegos ISY
# Main Menu buttons
mainMenuSelectTicTacToe=Tres en raya
mainMenuSelectReversi=Reversi
mainMenuSelectSudoku=Sudoku
mainMenuSelectBattleship=Batalla naval
mainMenuSelectOther=Otros
mainMenuSelectCredits=Cr\u00E9ditos
mainMenuSelectOptions=Opciones
mainMenuSelectQuit=Salir
# Quit Menu text and buttons
quitMenuTextSure=\u00BFEst\u00E1s seguro?
quitMenuButtonYes=S\u00ED
quitMenuButtonNo=No

View File

@@ -0,0 +1,17 @@
# Window title
windowTitle=S\u00E9lecteur de jeux ISY
# Main Menu buttons
mainMenuSelectTicTacToe=Morpion
mainMenuSelectReversi=Reversi
mainMenuSelectSudoku=Sudoku
mainMenuSelectBattleship=Bataille navale
mainMenuSelectOther=Autres
mainMenuSelectCredits=Cr\u00E9dits
mainMenuSelectOptions=Options
mainMenuSelectQuit=Quitter
# Quit Menu text and buttons
quitMenuTextSure=\u00CAtes-vous s\u00FBr?
quitMenuButtonYes=Oui
quitMenuButtonNo=Non

View File

@@ -0,0 +1,17 @@
# Window title
windowTitle=Selettore giochi ISY
# Main Menu buttons
mainMenuSelectTicTacToe=Tris
mainMenuSelectReversi=Reversi
mainMenuSelectSudoku=Sudoku
mainMenuSelectBattleship=Battaglia navale
mainMenuSelectOther=Altro
mainMenuSelectCredits=Crediti
mainMenuSelectOptions=Opzioni
mainMenuSelectQuit=Esci
# Quit Menu text and buttons
quitMenuTextSure=Sei sicuro?
quitMenuButtonYes=S\u00EC
quitMenuButtonNo=No

View File

@@ -0,0 +1,17 @@
# Window title
windowTitle=ISY Spellen Kiezer
# Main Menu buttons
mainMenuSelectTicTacToe=Boter Kaas En Eieren
mainMenuSelectReversi=Reversi
mainMenuSelectSudoku=Sudoku
mainMenuSelectBattleship=Zeeslag
mainMenuSelectOther=Anders
mainMenuSelectCredits=Credits
mainMenuSelectOptions=Opties
mainMenuSelectQuit=Afsluiten
# Quit Menu text and buttons
quitMenuTextSure=Weet je het zeker?
quitMenuButtonYes=Ja
quitMenuButtonNo=Nee

View File

@@ -0,0 +1,30 @@
# suppress inspection "LossyEncoding" for whole file
# Window title
windowTitle=ISY \u6E38\u620F\u9009\u62E9\u5668
# ?????
# Main Menu buttons
mainMenuSelectTicTacToe=\u4E95\u5B57\u68CB
# ???
mainMenuSelectReversi=\u9ED1\u767D\u68CB
# ???
mainMenuSelectSudoku=\u6570\u72EC
# ??
mainMenuSelectBattleship=\u6D77\u6218\u68CB
# ???
mainMenuSelectOther=\u5176\u4ED6
# ??
mainMenuSelectCredits=\u5236\u4F5C\u4EBA\u5458
# ????
mainMenuSelectOptions=\u9009\u9879
# ??
mainMenuSelectQuit=\u9000\u51FA
# ??
# Quit Menu text and buttons
quitMenuTextSure=\u4F60\u786E\u5B9A\u5417\uFF1F
# ?????
quitMenuButtonYes=\u662F
# ?
quitMenuButtonNo=\u5426
# ?

View File

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

View File

@@ -13,6 +13,12 @@
</properties>
<dependencies>
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
@@ -90,6 +96,27 @@
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>25</version>
</dependency>
<!-- JavaFX Media -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</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>
<build>
@@ -103,24 +130,41 @@
<target>25</target>
<release>25</release>
<encoding>UTF-8</encoding>
<!-- <compilerArgs>-->
<!-- <arg>-XDcompilePolicy=simple</arg>-->
<!-- <arg>&#45;&#45;should-stop=ifError=FLOW</arg>-->
<!-- <arg>-Xplugin:ErrorProne</arg>-->
<!-- </compilerArgs>-->
<!-- <annotationProcessorPaths>-->
<!-- <path>-->
<!-- <groupId>com.google.errorprone</groupId>-->
<!-- <artifactId>error_prone_core</artifactId>-->
<!-- <version>2.42.0</version>-->
<!-- </path>-->
<!-- &lt;!&ndash; Other annotation processors go here.-->
<!-- If 'annotationProcessorPaths' is set, processors will no longer be-->
<!-- discovered on the regular -classpath; see also 'Using Error Prone-->
<!-- together with other annotation processors' below. &ndash;&gt;-->
<!-- </annotationProcessorPaths>-->
<!-- <fork>true</fork>-->
</configuration>
</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>

View File

@@ -0,0 +1,159 @@
package org.toop.framework;
import java.net.NetworkInterface;
import java.time.Instant;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicLong;
/**
* A thread-safe, distributed unique ID generator following the Snowflake pattern.
* <p>
* Each generated 64-bit ID encodes:
* <ul>
* <li>41-bit timestamp (milliseconds since custom epoch)</li>
* <li>10-bit machine identifier</li>
* <li>12-bit sequence number for IDs generated in the same millisecond</li>
* </ul>
* </p>
*
* <p>This implementation ensures:
* <ul>
* <li>IDs are unique per machine.</li>
* <li>Monotonicity within the same machine.</li>
* <li>Safe concurrent generation via synchronized {@link #nextId()}.</li>
* </ul>
* </p>
*
* <p>Custom epoch is set to {@code 2025-01-01T00:00:00Z}.</p>
*
* <p>Usage example:</p>
* <pre>{@code
* SnowflakeGenerator generator = new SnowflakeGenerator();
* long id = generator.nextId();
* }</pre>
*/
public class SnowflakeGenerator {
/**
* Custom epoch in milliseconds (2025-01-01T00:00:00Z).
*/
private static final long EPOCH = Instant.parse("2025-01-01T00:00:00Z").toEpochMilli();
// Bit allocations
private static final long TIMESTAMP_BITS = 41;
private static final long MACHINE_BITS = 10;
private static final long SEQUENCE_BITS = 12;
// Maximum values for each component
private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1;
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
private static final long MAX_TIMESTAMP = (1L << TIMESTAMP_BITS) - 1;
// Bit shifts for composing the ID
private static final long MACHINE_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + MACHINE_BITS;
/**
* Unique machine identifier derived from network interfaces (10 bits).
*/
private static final long machineId = SnowflakeGenerator.genMachineId();
private final AtomicLong lastTimestamp = new AtomicLong(-1L);
private long sequence = 0L;
/**
* Generates a 10-bit machine identifier based on MAC addresses of network interfaces.
* Falls back to a random value if MAC cannot be determined.
*/
private static long genMachineId() {
try {
StringBuilder sb = new StringBuilder();
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
byte[] mac = ni.getHardwareAddress();
if (mac != null) {
for (byte b : mac) sb.append(String.format("%02X", b));
}
}
return sb.toString().hashCode() & 0x3FF; // limit to 10 bits
} catch (Exception e) {
return (long) (Math.random() * 1024); // fallback
}
}
/**
* For testing: manually set the last generated timestamp.
* @param l timestamp in milliseconds
*/
void setTime(long l) {
this.lastTimestamp.set(l);
}
/**
* Constructs a SnowflakeGenerator.
* Validates that the machine ID is within allowed range.
* @throws IllegalArgumentException if machine ID is invalid
*/
public SnowflakeGenerator() {
if (machineId < 0 || machineId > MAX_MACHINE_ID) {
throw new IllegalArgumentException(
"Machine ID must be between 0 and " + MAX_MACHINE_ID);
}
}
/**
* Generates the next unique ID.
* <p>
* If multiple IDs are generated in the same millisecond, a sequence number
* is incremented. If the sequence overflows, waits until the next millisecond.
* </p>
*
* @return a unique 64-bit ID
* @throws IllegalStateException if clock moves backwards or timestamp exceeds 41-bit limit
*/
public synchronized long nextId() {
long currentTimestamp = timestamp();
if (currentTimestamp < lastTimestamp.get()) {
throw new IllegalStateException("Clock moved backwards. Refusing to generate id.");
}
if (currentTimestamp > MAX_TIMESTAMP) {
throw new IllegalStateException("Timestamp bits overflow, Snowflake expired.");
}
if (currentTimestamp == lastTimestamp.get()) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
currentTimestamp = waitNextMillis(currentTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp.set(currentTimestamp);
return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
| (machineId << MACHINE_SHIFT)
| sequence;
}
/**
* Waits until the next millisecond if sequence overflows.
* @param lastTimestamp previous timestamp
* @return new timestamp
*/
private long waitNextMillis(long lastTimestamp) {
long ts = timestamp();
while (ts <= lastTimestamp) {
ts = timestamp();
}
return ts;
}
/**
* Returns current system timestamp in milliseconds.
*/
private long timestamp() {
return System.currentTimeMillis();
}
}

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,246 @@
package org.toop.framework.asset;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.toop.framework.asset.events.AssetLoaderEvents;
import org.toop.framework.asset.resources.*;
import org.toop.framework.eventbus.EventFlow;
import org.reflections.Reflections;
import java.io.File;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
/**
* Responsible for loading assets from a file system directory into memory.
* <p>
* The {@code AssetLoader} scans a root folder recursively, identifies files,
* and maps them to registered resource types based on file extensions and
* {@link FileExtension} annotations.
* It supports multiple resource types including {@link PreloadResource} (automatically loaded)
* and {@link BundledResource} (merged across multiple files).
* </p>
*
* <p>Assets are stored in a static, thread-safe list and can be retrieved
* through {@link AssetManager}.</p>
*
* <p>Features:</p>
* <ul>
* <li>Recursive directory scanning for assets.</li>
* <li>Automatic registration of resource classes via reflection.</li>
* <li>Bundled resource support: multiple files merged into a single resource instance.</li>
* <li>Preload resources automatically invoke {@link PreloadResource#load()}.</li>
* <li>Progress tracking via {@link AssetLoaderEvents.LoadingProgressUpdate} events.</li>
* </ul>
*
* <p>Usage example:</p>
* <pre>{@code
* AssetLoader loader = new AssetLoader("assets");
* double progress = loader.getProgress();
* List<Asset<? extends BaseResource>> loadedAssets = loader.getAssets();
* }</pre>
*/
public class AssetLoader {
private static final Logger logger = LogManager.getLogger(AssetLoader.class);
private static final List<Asset<? extends BaseResource>> assets = new CopyOnWriteArrayList<>();
private final Map<String, Function<File, ? extends BaseResource>> registry = new ConcurrentHashMap<>();
private final AtomicInteger loadedCount = new AtomicInteger(0);
private int totalCount = 0;
/**
* Constructs an AssetLoader and loads assets from the given root folder.
* @param rootFolder the folder containing asset files
*/
public AssetLoader(File rootFolder) {
autoRegisterResources();
List<File> foundFiles = new ArrayList<>();
fileSearcher(rootFolder, foundFiles);
this.totalCount = foundFiles.size();
// measure memory before loading
long before = getUsedMemory();
loader(foundFiles);
// ~measure memory after loading
long after = getUsedMemory();
long used = after - before;
logger.info("Total files loaded: {}", this.totalCount);
logger.info("Memory used by assets: ~{} MB", used / (1024 * 1024));
}
private static long getUsedMemory() {
Runtime runtime = Runtime.getRuntime();
return runtime.totalMemory() - runtime.freeMemory();
}
/**
* Constructs an AssetLoader from a folder path.
* @param rootFolder the folder path containing assets
*/
public AssetLoader(String rootFolder) {
this(new File(rootFolder));
}
/**
* Returns the current progress of loading assets (0.0 to 1.0).
* @return progress as a double
*/
public double getProgress() {
return (totalCount == 0) ? 1.0 : (loadedCount.get() / (double) totalCount);
}
/**
* Returns the number of assets loaded so far.
* @return loaded count
*/
public int getLoadedCount() {
return loadedCount.get();
}
/**
* Returns the total number of files found to load.
* @return total asset count
*/
public int getTotalCount() {
return totalCount;
}
/**
* Returns a snapshot list of all assets loaded by this loader.
* @return list of loaded assets
*/
public List<Asset<? extends BaseResource>> getAssets() {
return new ArrayList<>(assets);
}
/**
* Registers a factory for a specific file extension.
* @param extension the file extension (without dot)
* @param factory a function mapping a File to a resource instance
* @param <T> the type of resource
*/
public <T extends BaseResource> void register(String extension, Function<File, T> factory) {
this.registry.put(extension, factory);
}
/**
* Maps a file to a resource instance based on its extension and registered factories.
*/
private <T extends BaseResource> T resourceMapper(File file, Class<T> type) {
String ext = getExtension(file.getName());
Function<File, ? extends BaseResource> factory = 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);
}
/**
* Loads the given list of files into assets, handling bundled and preload resources.
*/
private void loader(List<File> files) {
Map<String, BundledResource> bundledResources = new HashMap<>();
for (File file : files) {
BaseResource resource = resourceMapper(file, BaseResource.class);
switch (resource) {
case null -> {
continue;
}
case BundledResource br -> {
String key = resource.getClass().getName() + ":" + br.getBaseName();
if (bundledResources.containsKey(key)) {
bundledResources.get(key).loadFile(file);
resource = (BaseResource) bundledResources.get(key);
} else {
br.loadFile(file);
bundledResources.put(key, br);
}
}
case PreloadResource pr -> pr.load();
default -> {
}
}
BaseResource finalResource = resource;
boolean alreadyAdded = assets.stream()
.anyMatch(a -> a.getResource() == finalResource);
if (!alreadyAdded) {
assets.add(new Asset<>(file.getName(), resource));
}
logger.info("Loaded {} from {}", resource.getClass().getSimpleName(), file.getAbsolutePath());
loadedCount.incrementAndGet();
new EventFlow()
.addPostEvent(new AssetLoaderEvents.LoadingProgressUpdate(loadedCount.get(), totalCount))
.postEvent();
}
}
/**
* Recursively searches a folder and adds all files to the foundFiles list.
*/
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);
}
}
}
/**
* Uses reflection to automatically register all {@link BaseResource} subclasses
* annotated with {@link FileExtension}.
*/
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()) {
registry.put(ext, file -> {
try {
return cls.getConstructor(File.class).newInstance(file);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
}
}
/**
* Extracts the base name from a file name, used for bundling multiple files.
*/
private static 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);
}
/**
* Returns the file extension of a given file name (without dot).
*/
public static String getExtension(String name) {
int i = name.lastIndexOf('.');
return (i > 0) ? name.substring(i + 1) : "";
}
}

View File

@@ -0,0 +1,150 @@
package org.toop.framework.asset;
import org.toop.framework.asset.resources.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Centralized manager for all loaded assets in the application.
* <p>
* {@code AssetManager} maintains a thread-safe registry of {@link Asset} objects
* and provides utility methods to retrieve assets by name, ID, or type.
* It works together with {@link AssetLoader} to register assets automatically
* when they are loaded from the file system.
* </p>
*
* <p>Key responsibilities:</p>
* <ul>
* <li>Storing all loaded assets in a concurrent map.</li>
* <li>Providing typed access to asset resources.</li>
* <li>Allowing lookup by asset name or ID.</li>
* <li>Supporting retrieval of all assets of a specific {@link BaseResource} subclass.</li>
* </ul>
*
* <p>Example usage:</p>
* <pre>{@code
* // Load assets from a loader
* AssetLoader loader = new AssetLoader(new File("RootFolder"));
* AssetManager.loadAssets(loader);
*
* // Retrieve a single resource
* ImageAsset background = AssetManager.get("background.jpg");
*
* // Retrieve all fonts
* List<Asset<FontAsset>> fonts = AssetManager.getAllOfType(FontAsset.class);
*
* // Retrieve by asset name or optional lookup
* Optional<Asset<? extends BaseResource>> maybeAsset = AssetManager.findByName("menu.css");
* }</pre>
*
* <p>Notes:</p>
* <ul>
* <li>All retrieval methods are static and thread-safe.</li>
* <li>The {@link #get(String)} method may require casting if the asset type is not known at compile time.</li>
* <li>Assets should be loaded via {@link AssetLoader} before retrieval.</li>
* </ul>
*/
public class AssetManager {
private static final AssetManager INSTANCE = new AssetManager();
private static final Map<String, Asset<? extends BaseResource>> assets = new ConcurrentHashMap<>();
private AssetManager() {}
/**
* Returns the singleton instance of {@code AssetManager}.
*
* @return the shared instance
*/
public static AssetManager getInstance() {
return INSTANCE;
}
/**
* Loads all assets from a given {@link AssetLoader} into the manager.
*
* @param loader the loader that has already loaded assets
*/
public synchronized static void loadAssets(AssetLoader loader) {
for (var asset : loader.getAssets()) {
assets.put(asset.getName(), asset);
}
}
/**
* Retrieve the resource of a given name, cast to the expected type.
*
* @param name the asset name
* @param <T> the expected resource type
* @return the resource, or null if not found
*/
@SuppressWarnings("unchecked")
public static <T extends BaseResource> T get(String name) {
Asset<T> asset = (Asset<T>) assets.get(name);
if (asset == null) return null;
return asset.getResource();
}
/**
* Retrieve all assets of a specific resource type.
*
* @param type the class type to filter
* @param <T> the resource type
* @return a list of assets matching the type
*/
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()) {
if (type.isInstance(asset.getResource())) {
@SuppressWarnings("unchecked")
Asset<T> typed = (Asset<T>) asset;
list.add(typed);
}
}
return list;
}
/**
* Retrieve an asset by its unique ID.
*
* @param id the asset ID
* @return the asset, or null if not found
*/
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;
}
/**
* Retrieve an asset by its name.
*
* @param name the asset name
* @return the asset, or null if not found
*/
public static Asset<? extends BaseResource> getByName(String name) {
return assets.get(name);
}
/**
* Attempt to find an asset by name, returning an {@link Optional}.
*
* @param name the asset name
* @return an Optional containing the asset if found
*/
public static Optional<Asset<? extends BaseResource>> findByName(String name) {
return Optional.ofNullable(assets.get(name));
}
/**
* Add a new asset to the manager.
*
* @param asset the asset to add
*/
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 AssetLoaderEvents {
public record LoadingProgressUpdate(int hasLoadedAmount, int isLoadingAmount) implements EventWithoutSnowflake {}
}

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,67 @@
package org.toop.framework.asset.resources;
import java.io.File;
/**
* Represents a resource that can be composed of multiple files, or "bundled" together
* under a common base name.
*
* <p>Implementing classes allow an {@link org.toop.framework.asset.AssetLoader}
* to automatically merge multiple related files into a single resource instance.</p>
*
* <p>Typical use cases include:</p>
* <ul>
* <li>Localization assets, where multiple `.properties` files (e.g., `messages_en.properties`,
* `messages_nl.properties`) are grouped under the same logical resource.</li>
* <li>Sprite sheets, tile sets, or other multi-file resources that logically belong together.</li>
* </ul>
*
* <p>Implementing classes must provide:</p>
* <ul>
* <li>{@link #loadFile(File)}: Logic to load or merge an individual file into the resource.</li>
* <li>{@link #getBaseName()}: A consistent base name used to group multiple files into this resource.</li>
* </ul>
*
* <p>Example usage:</p>
* <pre>{@code
* public class LocalizationAsset extends BaseResource implements BundledResource {
* private final String baseName;
*
* public LocalizationAsset(File file) {
* super(file);
* this.baseName = extractBaseName(file.getName());
* loadFile(file);
* }
*
* @Override
* public void loadFile(File file) {
* // merge file into existing bundles
* }
*
* @Override
* public String getBaseName() {
* return baseName;
* }
* }
* }</pre>
*
* <p>When used with an asset loader, all files sharing the same base name are
* automatically merged into a single resource instance.</p>
*/
public interface BundledResource {
/**
* Load or merge an additional file into this resource.
*
* @param file the file to load or merge
*/
void loadFile(File file);
/**
* Return a base name for grouping multiple files into this single resource.
* Files with the same base name are automatically merged by the loader.
*
* @return the base name used to identify this bundled resource
*/
String getBaseName();
}

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,41 @@
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;
/**
* Annotation to declare which file extensions a {@link BaseResource} subclass
* can handle.
*
* <p>This annotation is processed by the {@link org.toop.framework.asset.AssetLoader}
* to automatically register resource types for specific file extensions.
* Each extension listed will be mapped to the annotated resource class,
* allowing the loader to instantiate the correct type when scanning files.</p>
*
* <p>Usage example:</p>
* <pre>{@code
* @FileExtension({"png", "jpg"})
* public class ImageAsset extends BaseResource implements LoadableResource {
* ...
* }
* }</pre>
*
* <p>Key points:</p>
* <ul>
* <li>The annotation is retained at runtime for reflection-based registration.</li>
* <li>Can only be applied to types (classes) that extend {@link BaseResource}.</li>
* <li>Multiple extensions can be specified in the {@code value()} array.</li>
* </ul>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FileExtension {
/**
* The list of file extensions (without leading dot) that the annotated resource class can handle.
*
* @return array of file extensions
*/
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 PreloadResource {
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,64 @@
package org.toop.framework.asset.resources;
/**
* Represents a resource that can be explicitly loaded and unloaded.
* <p>
* Any class implementing {@code LoadableResource} is responsible for managing its own
* loading and unloading logic, such as reading files, initializing data structures,
* or allocating external resources.
* </p>
*
* <p>Implementing classes must define the following behaviors:</p>
* <ul>
* <li>{@link #load()}: Load the resource into memory or perform necessary initialization.</li>
* <li>{@link #unload()}: Release any held resources or memory when the resource is no longer needed.</li>
* <li>{@link #isLoaded()}: Return {@code true} if the resource has been successfully loaded and is ready for use, {@code false} otherwise.</li>
* </ul>
*
* <p>Typical usage:</p>
* <pre>{@code
* public class MyFontAsset extends BaseResource implements LoadableResource {
* private boolean loaded = false;
*
* @Override
* public void load() {
* // Load font file into memory
* loaded = true;
* }
*
* @Override
* public void unload() {
* // Release resources if needed
* loaded = false;
* }
*
* @Override
* public boolean isLoaded() {
* return loaded;
* }
* }
* }</pre>
*
* <p>This interface is commonly used with {@link PreloadResource} to allow automatic
* loading by an {@link org.toop.framework.asset.AssetLoader} if desired.</p>
*/
public interface LoadableResource {
/**
* Load the resource into memory or initialize it.
* This method may throw runtime exceptions if loading fails.
*/
void load();
/**
* Unload the resource and free any associated resources.
* After this call, {@link #isLoaded()} should return false.
*/
void unload();
/**
* Check whether the resource has been successfully loaded.
*
* @return true if the resource is loaded and ready for use, false otherwise
*/
boolean isLoaded();
}

View File

@@ -0,0 +1,90 @@
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, BundledResource {
private final Map<Locale, ResourceBundle> bundles = new HashMap<>();
private boolean isLoaded = false;
private final Locale fallback = Locale.forLanguageTag("");
public LocalizationAsset(File file) {
super(file);
}
@Override
public void load() {
loadFile(getFile());
isLoaded = true;
}
@Override
public void unload() {
bundles.clear();
isLoaded = false;
}
@Override
public boolean isLoaded() {
return isLoaded;
}
public String getString(String key, Locale locale) {
Locale target = findBestLocale(locale);
ResourceBundle bundle = bundles.get(target);
if (bundle == null) throw new MissingResourceException(
"No bundle for locale: " + target, getClass().getName(), key);
return bundle.getString(key);
}
private Locale findBestLocale(Locale locale) {
if (bundles.containsKey(locale)) return locale;
for (Locale l : bundles.keySet()) {
if (l.getLanguage().equals(locale.getLanguage())) return l;
}
return fallback;
}
public Set<Locale> getAvailableLocales() {
return Collections.unmodifiableSet(bundles.keySet());
}
@Override
public void loadFile(File file) {
String baseName = getBaseName(file.getName());
try (InputStreamReader reader =
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
Locale locale = extractLocale(file.getName(), baseName);
bundles.put(locale, new PropertyResourceBundle(reader));
} catch (IOException e) {
throw new RuntimeException("Failed to load localization file: " + file, e);
}
isLoaded = true;
}
@Override
public String getBaseName() {
return getBaseName(getFile().getName());
}
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 fallback;
}
}

View File

@@ -0,0 +1,38 @@
package org.toop.framework.asset.resources;
import javafx.scene.media.Media;
import java.io.*;
@FileExtension({"mp3"})
public class MusicAsset extends BaseResource implements LoadableResource {
private Media media;
public MusicAsset(final File audioFile) {
super(audioFile);
}
public Media getMedia() {
if (media == null) {
media = new Media(file.toURI().toString());
}
return media;
}
@Override
public void load() {
if (media == null) media = new Media(file.toURI().toString());
this.isLoaded = true;
}
@Override
public void unload() {
media = null;
isLoaded = false;
}
@Override
public boolean isLoaded() {
return isLoaded;
}
}

View File

@@ -0,0 +1,37 @@
package org.toop.framework.asset.resources;
/**
* Marker interface for resources that should be **automatically loaded** by the {@link org.toop.framework.asset.AssetLoader}.
*
* <p>Extends {@link LoadableResource}, so any implementing class must provide the standard
* {@link LoadableResource#load()} and {@link LoadableResource#unload()} methods, as well as the
* {@link LoadableResource#isLoaded()} check.</p>
*
* <p>When a resource implements {@code PreloadResource}, the {@code AssetLoader} will invoke
* {@link LoadableResource#load()} automatically after the resource is discovered and instantiated,
* without requiring manual loading by the user.</p>
*
* <p>Typical usage:</p>
* <pre>{@code
* public class MyFontAsset extends BaseResource implements PreloadResource {
* @Override
* public void load() {
* // load the font into memory
* }
*
* @Override
* public void unload() {
* // release resources if needed
* }
*
* @Override
* public boolean isLoaded() {
* return loaded;
* }
* }
* }</pre>
*
* <p>Note: Only use this interface for resources that are safe to load at startup, as it may
* increase memory usage or startup time.</p>
*/
public interface PreloadResource extends LoadableResource {}

View File

@@ -0,0 +1,54 @@
package org.toop.framework.asset.resources;
import javafx.scene.media.Media;
import javax.sound.sampled.*;
import java.io.*;
import java.net.URI;
@FileExtension({"wav"})
public class SoundEffectAsset extends BaseResource implements LoadableResource {
public SoundEffectAsset(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,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,179 @@
package org.toop.framework.audio;
import javafx.application.Platform;
import javafx.scene.media.MediaPlayer;
import org.toop.framework.SnowflakeGenerator;
import org.toop.framework.asset.Asset;
import org.toop.framework.asset.AssetManager;
import org.toop.framework.asset.resources.MusicAsset;
import org.toop.framework.asset.resources.SoundEffectAsset;
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 List<MediaPlayer> activeMusic = new ArrayList<>();
private final Queue<MusicAsset> backgroundMusicQueue = new LinkedList<>();
private final Map<Long, Clip> activeSoundEffects = new HashMap<>();
private final HashMap<String, SoundEffectAsset> audioResources = new HashMap<>();
private final SnowflakeGenerator idGenerator = new SnowflakeGenerator(); // TODO: Don't create a new generator
private double volume = 1.0;
public SoundManager() {
// Get all Audio Resources and add them to a list.
for (Asset<SoundEffectAsset> asset : AssetManager.getAllOfType(SoundEffectAsset.class)) {
try {
this.addAudioResource(asset);
} catch (IOException | LineUnavailableException | UnsupportedAudioFileException e) {
throw new RuntimeException(e);
}
}
new EventFlow()
.listen(this::handlePlaySound)
.listen(this::handleStopSound)
.listen(this::handleMusicStart)
.listen(this::handleVolumeChange)
.listen(AudioEvents.playOnClickButton.class, _ -> {
try {
playSound("hitsound0.wav", false);
} catch (UnsupportedAudioFileException | LineUnavailableException | IOException e) {
throw new RuntimeException(e);
}
});
}
private void handlePlaySound(AudioEvents.PlayAudio event) {
try {
this.playSound(event.fileName(), 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<SoundEffectAsset> audioAsset)
throws IOException, UnsupportedAudioFileException, LineUnavailableException {
this.audioResources.put(audioAsset.getName(), audioAsset.getResource());
}
private void handleVolumeChange(AudioEvents.ChangeVolume event) {
if (event.newVolume() > 1.0) this.volume = 1.0;
else this.volume = Math.max(event.newVolume(), 0.0);
for (MediaPlayer mediaPlayer : this.activeMusic) {
mediaPlayer.setVolume(this.volume);
}
}
private void handleMusicStart(AudioEvents.StartBackgroundMusic e) {
backgroundMusicQueue.clear();
Platform.runLater(() -> {
backgroundMusicQueue.addAll(
AssetManager.getAllOfType(MusicAsset.class).stream()
.map(Asset::getResource)
.toList()
);
backgroundMusicPlayer();
});
}
private void addBackgroundMusic(MusicAsset musicAsset) {
backgroundMusicQueue.add(musicAsset);
}
private void backgroundMusicPlayer() {
MusicAsset ma = backgroundMusicQueue.poll();
if (ma == null) return;
MediaPlayer mediaPlayer = new MediaPlayer(ma.getMedia());
mediaPlayer.setOnEndOfMedia(() -> {
addBackgroundMusic(ma);
activeMusic.remove(mediaPlayer);
mediaPlayer.dispose();
ma.unload();
backgroundMusicPlayer(); // play next
});
mediaPlayer.setOnStopped(() -> {
addBackgroundMusic(ma);
activeMusic.remove(mediaPlayer);
ma.unload();
});
mediaPlayer.setOnError(() -> {
addBackgroundMusic(ma);
activeMusic.remove(mediaPlayer);
ma.unload();
});
mediaPlayer.setVolume(this.volume);
mediaPlayer.play();
activeMusic.add(mediaPlayer);
}
private long playSound(String audioFileName, boolean loop) throws UnsupportedAudioFileException, LineUnavailableException, IOException {
SoundEffectAsset 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
activeSoundEffects.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()) {
activeSoundEffects.remove(clipId);
clip.close();
}
});
// Return id so it can be stopped
return clipId;
}
public void stopSound(long clipId) {
Clip clip = activeSoundEffects.get(clipId);
if (clip == null) {
return;
}
clip.stop();
clip.close();
activeSoundEffects.remove(clipId);
}
public void stopAllSounds() {
for (Clip clip : activeSoundEffects.values()) {
clip.stop();
clip.close();
}
activeSoundEffects.clear();
}
}

View File

@@ -0,0 +1,17 @@
package org.toop.framework.audio.events;
import org.toop.framework.asset.resources.MusicAsset;
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 fileName, boolean loop)
implements EventWithoutSnowflake {}
public record StopAudio(long clipId) implements EventWithoutSnowflake {}
public record StartBackgroundMusic() implements EventWithoutSnowflake {}
public record ChangeVolume(double newVolume) implements EventWithoutSnowflake {}
public record playOnClickButton() implements EventWithoutSnowflake {}
}

View File

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

View File

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

@@ -1,68 +0,0 @@
package org.toop.framework.eventbus;
import java.util.concurrent.atomic.AtomicLong;
public class SnowflakeGenerator {
// Epoch start (choose your custom epoch to reduce bits wasted on old time)
private static final long EPOCH = 1700000000000L; // ~2023-11-15
// Bit allocations
private static final long TIMESTAMP_BITS = 41;
private static final long MACHINE_BITS = 10;
private static final long SEQUENCE_BITS = 12;
// Max values
private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1;
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
// Bit shifts
private static final long MACHINE_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + MACHINE_BITS;
private final long machineId;
private final AtomicLong lastTimestamp = new AtomicLong(-1L);
private long sequence = 0L;
public SnowflakeGenerator(long machineId) {
if (machineId < 0 || machineId > MAX_MACHINE_ID) {
throw new IllegalArgumentException("Machine ID must be between 0 and " + MAX_MACHINE_ID);
}
this.machineId = machineId;
}
public synchronized long nextId() {
long currentTimestamp = timestamp();
if (currentTimestamp < lastTimestamp.get()) {
throw new IllegalStateException("Clock moved backwards. Refusing to generate id.");
}
if (currentTimestamp == lastTimestamp.get()) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// Sequence overflow, wait for next millisecond
currentTimestamp = waitNextMillis(currentTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp.set(currentTimestamp);
return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
| (machineId << MACHINE_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long ts = timestamp();
while (ts <= lastTimestamp) {
ts = timestamp();
}
return ts;
}
private long timestamp() {
return System.currentTimeMillis();
}
}

View File

@@ -7,53 +7,72 @@ import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.function.Supplier;
import org.toop.framework.eventbus.EventFlow;
import org.toop.framework.networking.events.NetworkEvents;
public class NetworkingClient {
private static final Logger logger = LogManager.getLogger(NetworkingClient.class);
final Bootstrap bootstrap = new Bootstrap();
final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
private String connectionUuid;
private long connectionId;
private String host;
private int port;
private Channel channel;
private NetworkingGameClientHandler handler;
public NetworkingClient(
Supplier<? extends NetworkingGameClientHandler> handlerFactory,
Supplier<NetworkingGameClientHandler> handlerFactory,
String host,
int port) {
int port,
long connectionId) {
this.connectionId = connectionId;
try {
this.bootstrap.group(this.workerGroup);
this.bootstrap.channel(NioSocketChannel.class);
this.bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
this.bootstrap.handler(new ChannelInitializer<SocketChannel>() {
Bootstrap bootstrap = new Bootstrap();
EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
bootstrap.group(workerGroup);
bootstrap.channel(NioSocketChannel.class);
bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
bootstrap.handler(
new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
handler = handlerFactory.get();
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new LineBasedFrameDecoder(1024)); // split at \n
pipeline.addLast(new StringDecoder()); // bytes -> String
pipeline.addLast(
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.host = host;
this.port = port;
} catch (Exception e) {
logger.error("Failed to create networking client instance", e);
}
}
public NetworkingGameClientHandler getHandler() {
return handler;
return this.handler;
}
public void setConnectionUuid(String connectionUuid) {
this.connectionUuid = connectionUuid;
public String getHost() {
return this.host;
}
public int getPort() {
return this.port;
}
public void setConnectionId(long connectionId) {
this.connectionId = connectionId;
}
public boolean isChannelActive() {
@@ -64,72 +83,40 @@ public class NetworkingClient {
String literalMsg = msg.replace("\n", "\\n").replace("\r", "\\r");
if (isChannelActive()) {
this.channel.writeAndFlush(msg);
logger.info("Connection {} sent message: {}", this.channel.remoteAddress(), literalMsg);
logger.info(
"Connection {} sent message: '{}'", this.channel.remoteAddress(), literalMsg);
} else {
logger.warn("Cannot send message: {}, connection inactive.", literalMsg);
logger.warn("Cannot send message: '{}', connection inactive.", literalMsg);
}
}
public void writeAndFlushnl(String msg) {
if (isChannelActive()) {
this.channel.writeAndFlush(msg + "\n");
logger.info("Connection {} sent message: {}", this.channel.remoteAddress(), msg);
this.channel.writeAndFlush(msg + "\r\n");
logger.info("Connection {} sent message: '{}'", this.channel.remoteAddress(), msg);
} else {
logger.warn("Cannot send message: {}, connection inactive.", msg);
logger.warn("Cannot send message: '{}', connection inactive.", msg);
}
}
public void login(String username) {
this.writeAndFlush("login " + username + "\n");
}
public void logout() {
this.writeAndFlush("logout\n");
}
public void sendMove(int move) {
this.writeAndFlush("move " + move + "\n"); // append \n so server receives a full line
}
public void getGamelist() {
this.writeAndFlush("get gamelist\n");
}
public void getPlayerlist() {
this.writeAndFlush("get playerlist\n");
}
public void subscribe(String gameType) {
this.writeAndFlush("subscribe " + gameType + "\n");
}
public void forfeit() {
this.writeAndFlush("forfeit\n");
}
public void challenge(String playerName, String gameType) {
this.writeAndFlush("challenge " + playerName + " " + gameType + "\n");
}
public void acceptChallenge(String challengeNumber) {
this.writeAndFlush("challenge accept " + challengeNumber + "\n");
}
public void sendChatMessage(String message) {
this.writeAndFlush("message " + "\"" + message + "\"" + "\n");
}
public void help(String command) {
this.writeAndFlush("help " + command + "\n");
}
public void closeConnection() {
if (this.channel != null && this.channel.isActive()) {
this.channel.close().addListener(future -> {
this.channel
.close()
.addListener(
future -> {
if (future.isSuccess()) {
logger.info("Connection {} closed successfully", this.channel.remoteAddress());
logger.info(
"Connection {} closed successfully",
this.channel.remoteAddress());
new EventFlow()
.addPostEvent(
new NetworkEvents.ClosedConnection(
this.connectionId))
.asyncPostEvent();
} else {
logger.error("Error closing connection {}. Error: {}",
logger.error(
"Error closing connection {}. Error: {}",
this.channel.remoteAddress(),
future.cause().getMessage());
}
@@ -137,4 +124,7 @@ public class NetworkingClient {
}
}
public long getId() {
return this.connectionId;
}
}

View File

@@ -2,10 +2,9 @@ package org.toop.framework.networking;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.toop.framework.SnowflakeGenerator;
import org.toop.framework.eventbus.EventFlow;
import org.toop.framework.networking.events.NetworkEvents;
@@ -14,111 +13,189 @@ public class NetworkingClientManager {
private static final Logger logger = LogManager.getLogger(NetworkingClientManager.class);
/** Map of serverId -> Server instances */
private final Map<String, NetworkingClient> networkClients = new ConcurrentHashMap<>();
final Map<Long, NetworkingClient> networkClients = new ConcurrentHashMap<>();
/** Starts a connection manager, to manage, connections. */
public NetworkingClientManager() throws NetworkingInitializationException {
try {
new EventFlow().listen(NetworkEvents.StartClientRequest.class, this::handleStartClientRequest);
new EventFlow().listen(NetworkEvents.StartClient.class, this::handleStartClient);
new EventFlow().listen(NetworkEvents.SendCommand.class, this::handleCommand);
new EventFlow().listen(NetworkEvents.CloseClient.class, this::handleCloseClient);
new EventFlow().listen(NetworkEvents.RequestsAllClients.class, this::getAllConnections);
new EventFlow().listen(NetworkEvents.ForceCloseAllClients.class, this::shutdownAll);
new EventFlow()
.listen(this::handleStartClient)
.listen(this::handleCommand)
.listen(this::handleSendLogin)
.listen(this::handleSendLogout)
.listen(this::handleSendGetPlayerlist)
.listen(this::handleSendGetGamelist)
.listen(this::handleSendSubscribe)
.listen(this::handleSendMove)
.listen(this::handleSendChallenge)
.listen(this::handleSendAcceptChallenge)
.listen(this::handleSendForfeit)
.listen(this::handleSendMessage)
.listen(this::handleSendHelp)
.listen(this::handleSendHelpForCommand)
.listen(this::handleCloseClient)
.listen(this::handleChangeClientHost)
.listen(this::handleGetAllConnections)
.listen(this::handleShutdownAll);
logger.info("NetworkingClientManager initialized");
} catch (Exception e) {
logger.error("Failed to initialize the client manager", e);
throw e;
}
}
private String startClientRequest(Supplier<? extends NetworkingGameClientHandler> handlerFactory,
String ip,
int port) {
String connectionUuid = UUID.randomUUID().toString();
try {
NetworkingClient client = new NetworkingClient(
handlerFactory,
long startClientRequest(String ip, int port) {
long connectionId = new SnowflakeGenerator().nextId(); // TODO: Maybe use the one generated
try { // With EventFlow
NetworkingClient client =
new NetworkingClient(
() -> new NetworkingGameClientHandler(connectionId),
ip,
port);
this.networkClients.put(connectionUuid, client);
port,
connectionId);
client.setConnectionId(connectionId);
this.networkClients.put(connectionId, client);
logger.info("New client started successfully for {}:{}", ip, port);
} catch (Exception e) {
logger.error(e);
}
logger.info("Client {} started", connectionUuid);
return connectionUuid;
return connectionId;
}
private void handleStartClientRequest(NetworkEvents.StartClientRequest request) {
request.future()
.complete(
this.startClientRequest(
request.handlerFactory(),
request.ip(),
request.port())); // TODO: Maybe post ConnectionEstablished event.
private long startClientRequest(String ip, int port, long clientId) {
try { // With EventFlow
NetworkingClient client =
new NetworkingClient(
() -> new NetworkingGameClientHandler(clientId), ip, port, clientId);
client.setConnectionId(clientId);
this.networkClients.replace(clientId, client);
logger.info(
"New client started successfully for {}:{}, replaced: {}", ip, port, clientId);
} catch (Exception e) {
logger.error(e);
}
logger.info("Client {} started", clientId);
return clientId;
}
private void handleStartClient(NetworkEvents.StartClient event) {
String uuid = this.startClientRequest(event.handlerFactory(), event.ip(), event.port());
new EventFlow().addPostEvent(NetworkEvents.StartClientSuccess.class,
uuid, event.eventSnowflake()
).asyncPostEvent();
void handleStartClient(NetworkEvents.StartClient event) {
long id = this.startClientRequest(event.ip(), event.port());
new Thread(
() -> {
try {
Thread.sleep(100); // TODO: Is this a good idea?
new EventFlow()
.addPostEvent(
NetworkEvents.StartClientResponse.class,
id,
event.eventSnowflake())
.asyncPostEvent();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
})
.start();
}
private void handleCommand(
void handleCommand(
NetworkEvents.SendCommand
event) { // TODO: Move this to ServerConnection class, keep it internal.
NetworkingClient client = this.networkClients.get(event.connectionId());
logger.info("Preparing to send command: {} to server: {}", event.args(), client);
if (client != null) {
String args = String.join(" ", event.args()) + "\n";
client.writeAndFlush(args);
} else {
logger.warn("Server {} not found for command '{}'", event.connectionId(), event.args());
}
NetworkingClient client = this.networkClients.get(event.clientId());
String args = String.join(" ", event.args());
sendCommand(client, args);
}
private void handleCloseClient(NetworkEvents.CloseClient event) {
NetworkingClient client = this.networkClients.get(event.connectionId());
void handleSendLogin(NetworkEvents.SendLogin event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, String.format("LOGIN %s", event.username()));
}
private void handleSendLogout(NetworkEvents.SendLogout event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, "LOGOUT");
}
private void handleSendGetPlayerlist(NetworkEvents.SendGetPlayerlist event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, "GET PLAYERLIST");
}
private void handleSendGetGamelist(NetworkEvents.SendGetGamelist event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, "GET GAMELIST");
}
private void handleSendSubscribe(NetworkEvents.SendSubscribe event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, String.format("SUBSCRIBE %s", event.gameType()));
}
private void handleSendMove(NetworkEvents.SendMove event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, String.format("MOVE %d", event.moveNumber()));
}
private void handleSendChallenge(NetworkEvents.SendChallenge event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(
client,
String.format("CHALLENGE %s %s", event.usernameToChallenge(), event.gameType()));
}
private void handleSendAcceptChallenge(NetworkEvents.SendAcceptChallenge event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, String.format("CHALLENGE ACCEPT %d", event.challengeId()));
}
private void handleSendForfeit(NetworkEvents.SendForfeit event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, "FORFEIT");
}
private void handleSendMessage(NetworkEvents.SendMessage event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, String.format("MESSAGE %s", event.message()));
}
private void handleSendHelp(NetworkEvents.SendHelp event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, "HELP");
}
private void handleSendHelpForCommand(NetworkEvents.SendHelpForCommand event) {
NetworkingClient client = this.networkClients.get(event.clientId());
sendCommand(client, String.format("HELP %s", event.command()));
}
private void sendCommand(NetworkingClient client, String command) {
logger.info(
"Preparing to send command: {} to server: {}:{}. clientId: {}",
command.trim(),
client.getHost(),
client.getPort(),
client.getId());
client.writeAndFlushnl(command);
}
private void handleChangeClientHost(NetworkEvents.ChangeClientHost event) {
NetworkingClient client = this.networkClients.get(event.clientId());
client.closeConnection();
startClientRequest(event.ip(), event.port(), event.clientId());
}
void handleCloseClient(NetworkEvents.CloseClient event) {
NetworkingClient client = this.networkClients.get(event.clientId());
client.closeConnection(); // TODO: Check if not blocking, what if error, mb not remove?
this.networkClients.remove(event.connectionId());
logger.info("Client {} closed successfully.", event.connectionId());
this.networkClients.remove(event.clientId());
logger.info("Client {} closed successfully.", event.clientId());
}
// private void handleReconnect(Events.ServerEvents.Reconnect event) {
// NetworkingClient client = this.networkClients.get(event.connectionId());
// if (client != null) {
// try {
// client;
// logger.info("Server {} reconnected", event.connectionId());
// } catch (Exception e) {
// logger.error("Server {} failed to reconnect", event.connectionId(), e);
// GlobalEventBus.post(new Events.ServerEvents.CouldNotConnect(event.connectionId()));
// }
// }
// } // TODO: Reconnect on disconnect
// private void handleChangeConnection(Events.ServerEvents.ChangeConnection event) {
// ServerConnection serverConnection = this.serverConnections.get(event.connectionId());
// if (serverConnection != null) {
// try {
// serverConnection.connect(event.ip(), event.port());
// logger.info("Server {} changed connection to {}:{}", event.connectionId(),
// event.ip(), event.port());
// } catch (Exception e) {
// logger.error("Server {} failed to change connection", event.connectionId(),
// e);
// GlobalEventBus.post(new
// Events.ServerEvents.CouldNotConnect(event.connectionId()));
// }
// }
// } TODO
private void getAllConnections(NetworkEvents.RequestsAllClients request) {
void handleGetAllConnections(NetworkEvents.RequestsAllClients request) {
List<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.clear();
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.ChannelInboundHandlerAdapter;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.toop.framework.eventbus.EventFlow;
import org.toop.framework.networking.events.NetworkEvents;
public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter {
private static final Logger logger = LogManager.getLogger(NetworkingGameClientHandler.class);
public NetworkingGameClientHandler() {}
private final long connectionId;
public NetworkingGameClientHandler(long connectionId) {
this.connectionId = connectionId;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
logger.debug("Received message from server-{}, data: {}", ctx.channel().remoteAddress(), msg);
String rec = msg.toString().trim();
// TODO: Handle server messages
if (rec.equalsIgnoreCase("err")) {
logger.error("server-{} send back error, data: {}", ctx.channel().remoteAddress(), msg);
return;
}
if (rec.equalsIgnoreCase("ok")) {
logger.info(
"Received OK message from server-{}, data: {}",
ctx.channel().remoteAddress(),
msg);
return;
}
if (rec.toLowerCase().startsWith("svr")) {
logger.info(
"Received SVR message from server-{}, data: {}",
ctx.channel().remoteAddress(),
msg);
new EventFlow()
.addPostEvent(new NetworkEvents.ServerResponse(this.connectionId))
.asyncPostEvent();
parseServerReturn(rec);
return;
}
logger.info(
"Received unparsed message from server-{}, data: {}",
ctx.channel().remoteAddress(),
msg);
}
private void parseServerReturn(String rec) {
String recSrvRemoved = rec.substring("SVR ".length());
Pattern gamePattern = Pattern.compile("GAME (\\w+)", Pattern.CASE_INSENSITIVE);
Matcher gameMatch = gamePattern.matcher(recSrvRemoved);
if (gameMatch.find()) {
switch (gameMatch.group(1)) {
case "YOURTURN":
gameYourTurnHandler(recSrvRemoved);
return;
case "MOVE":
gameMoveHandler(recSrvRemoved);
return;
case "MATCH":
gameMatchHandler(recSrvRemoved);
return;
case "CHALLENGE":
gameChallengeHandler(recSrvRemoved);
return;
case "WIN", "DRAW", "LOSE":
gameWinConditionHandler(recSrvRemoved);
return;
default:
return;
}
} else {
Pattern getPattern = Pattern.compile("(\\w+)", Pattern.CASE_INSENSITIVE);
Matcher getMatch = getPattern.matcher(recSrvRemoved);
if (getMatch.find()) {
switch (getMatch.group(1)) {
case "PLAYERLIST":
playerlistHandler(recSrvRemoved);
return;
case "GAMELIST":
gamelistHandler(recSrvRemoved);
return;
case "HELP":
helpHandler(recSrvRemoved);
return;
default:
return;
}
} else {
return; // TODO: Should be an error.
}
}
}
private void gameMoveHandler(String rec) {
String[] msg =
Pattern.compile(
"(?:player|details|move):\\s*\"?([^\",}]+)\"?",
Pattern.CASE_INSENSITIVE)
.matcher(rec)
.results()
.map(m -> m.group(1).trim())
.toArray(String[]::new);
new EventFlow()
.addPostEvent(
new NetworkEvents.GameMoveResponse(
this.connectionId, msg[0], msg[1], msg[2]))
.asyncPostEvent();
}
private void gameWinConditionHandler(String rec) {
String condition =
Pattern.compile("\\b(win|draw|lose)\\b", Pattern.CASE_INSENSITIVE)
.matcher(rec)
.results()
.toString()
.trim();
new EventFlow()
.addPostEvent(new NetworkEvents.GameResultResponse(this.connectionId, condition))
.asyncPostEvent();
}
private void gameChallengeHandler(String rec) {
boolean isCancelled = rec.toLowerCase().startsWith("challenge accepted");
try {
String[] msg =
Pattern.compile(
"(?:CHALLENGER|GAMETYPE|CHALLENGENUMBER):\\s*\"?(.*?)\"?\\s*(?:,|})")
.matcher(rec)
.results()
.map(m -> m.group().trim())
.toArray(String[]::new);
if (isCancelled)
new EventFlow()
.addPostEvent(
new NetworkEvents.ChallengeCancelledResponse(
this.connectionId, msg[0]))
.asyncPostEvent();
else
new EventFlow()
.addPostEvent(
new NetworkEvents.ChallengeResponse(
this.connectionId, msg[0], msg[1], msg[2]))
.asyncPostEvent();
} catch (ArrayIndexOutOfBoundsException e) {
logger.error("Array out of bounds for: {}", rec, e);
}
}
private void gameMatchHandler(String rec) {
try {
String[] msg =
Pattern.compile("\"([^\"]*)\"")
.matcher(rec)
.results()
.map(m -> m.group(1).trim())
.toArray(String[]::new);
// [0] playerToMove, [1] gameType, [2] opponent
new EventFlow()
.addPostEvent(
new NetworkEvents.GameMatchResponse(
this.connectionId, msg[0], msg[1], msg[2]))
.asyncPostEvent();
} catch (ArrayIndexOutOfBoundsException e) {
logger.error("Array out of bounds for: {}", rec, e);
}
}
private void gameYourTurnHandler(String rec) {
String msg =
Pattern.compile("TURNMESSAGE:\\s*\"([^\"]*)\"")
.matcher(rec)
.results()
.toString()
.trim();
new EventFlow()
.addPostEvent(new NetworkEvents.YourTurnResponse(this.connectionId, msg))
.asyncPostEvent();
}
private void playerlistHandler(String rec) {
String[] players =
Pattern.compile("\"([^\"]+)\"")
.matcher(rec)
.results()
.map(m -> m.group(1).trim())
.toArray(String[]::new);
new EventFlow()
.addPostEvent(new NetworkEvents.PlayerlistResponse(this.connectionId, players))
.asyncPostEvent();
}
private void gamelistHandler(String rec) {
String[] gameTypes =
Pattern.compile("\"([^\"]+)\"")
.matcher(rec)
.results()
.map(m -> m.group(1).trim())
.toArray(String[]::new);
new EventFlow()
.addPostEvent(new NetworkEvents.GamelistResponse(this.connectionId, gameTypes))
.asyncPostEvent();
}
private void helpHandler(String rec) {
logger.info(rec);
}
@Override
@@ -22,5 +227,4 @@ public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter {
logger.error(cause.getMessage(), cause);
ctx.close();
}
}

View File

@@ -1,77 +1,133 @@
package org.toop.framework.networking.events;
import org.toop.framework.eventbus.events.EventWithSnowflake;
import org.toop.framework.eventbus.events.EventWithoutSnowflake;
import org.toop.framework.eventbus.events.EventsBase;
import org.toop.framework.networking.NetworkingGameClientHandler;
import java.lang.reflect.RecordComponent;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.toop.framework.eventbus.events.EventWithSnowflake;
import org.toop.framework.eventbus.events.EventWithoutSnowflake;
import org.toop.framework.eventbus.events.EventsBase;
import org.toop.framework.networking.NetworkingClient;
/**
* A collection of networking-related event records for use with the {@link
* org.toop.framework.eventbus.GlobalEventBus}.
*
* <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 {
/**
* BLOCKING Requests all active connections. The result is returned via the provided
* CompletableFuture.
* Requests all active client connections.
*
* @param future List of all connections in string form.
* <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 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.
* <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>
* Event to start a new client connection.
*
* <p>
* 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>
* <p>Carries IP, port, and a unique event ID for correlation with responses.
*
* @param handlerFactory Factory for constructing a {@link NetworkingGameClientHandler}.
* @param ip The IP address of the server to connect to.
* @param port The port number of the server to connect to.
* @param eventSnowflake A unique identifier for this event, typically injected
* automatically by the {@link org.toop.framework.eventbus.EventFlow}.
* @param ip Server IP address.
* @param port Server port.
* @param eventSnowflake Unique event identifier for correlation.
*/
public record StartClient(
Supplier<? extends NetworkingGameClientHandler> handlerFactory,
String ip,
int port,
long eventSnowflake
) implements EventWithSnowflake {
public record StartClient(String ip, int port, long eventSnowflake)
implements EventWithSnowflake {
/**
* Returns a map representation of this event, where keys are record component names
* and values are their corresponding values. Useful for generic logging, debugging,
* or serializing events without hardcoding field names.
*
* @return a {@code Map<String, Object>} containing field names and values
*/
@Override
public Map<String, Object> result() {
return Stream.of(this.getClass().getRecordComponents())
.collect(Collectors.toMap(
.collect(
Collectors.toMap(
RecordComponent::getName,
rc -> {
try {
@@ -79,15 +135,9 @@ public class NetworkEvents extends EventsBase {
} catch (Exception e) {
throw new RuntimeException(e);
}
}
));
}));
}
/**
* Returns the unique event identifier used for correlating this event.
*
* @return the event ID string
*/
@Override
public long eventSnowflake() {
return this.eventSnowflake;
@@ -95,28 +145,18 @@ public class NetworkEvents extends EventsBase {
}
/**
* TODO: Update docs new input.
* BLOCKING Triggers starting a server connection and returns a future.
* Response confirming a client was started.
*
* @param ip The IP address of the server to connect to.
* @param port The port of the server to connect to.
* @param future Returns the UUID of the connection, when connection is established.
* @param clientId The client ID assigned to the new connection.
* @param eventSnowflake Event ID used for correlation.
*/
public record StartClientRequest(
Supplier<? 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)
public record StartClientResponse(long clientId, long eventSnowflake)
implements EventWithSnowflake {
@Override
public Map<String, Object> result() {
return Stream.of(this.getClass().getRecordComponents())
.collect(Collectors.toMap(
.collect(
Collectors.toMap(
RecordComponent::getName,
rc -> {
try {
@@ -124,8 +164,7 @@ public class NetworkEvents extends EventsBase {
} catch (Exception e) {
throw new RuntimeException(e);
}
}
));
}));
}
@Override
@@ -134,47 +173,41 @@ public class NetworkEvents extends EventsBase {
}
}
/** Generic server response. */
public record ServerResponse(long clientId) implements EventWithoutSnowflake {}
/**
* Triggers sending a command to a server.
* Request to send a command to a server.
*
* @param connectionId The UUID of the connection to send the command on.
* @param clientId The client connection ID.
* @param args The command arguments.
*/
public record SendCommand(String connectionId, String... args) implements EventWithoutSnowflake {}
/**
* Triggers reconnecting to a previous address.
*
* @param connectionId The identifier of the connection being reconnected.
*/
public record Reconnect(Object connectionId) implements EventWithoutSnowflake {}
public record SendCommand(long clientId, String... args) implements EventWithoutSnowflake {}
/** WIP (Not working) Request to reconnect a client to a previous address. */
public record Reconnect(long clientId) implements EventWithoutSnowflake {}
/**
* Triggers when the server client receives a message.
* Response triggered when a message is received from a server.
*
* @param ConnectionUuid The UUID of the connection that received the message.
* @param message The message received.
* @param clientId The connection ID that received the message.
* @param message The message content.
*/
public record ReceivedMessage(String ConnectionUuid, String message) implements EventWithoutSnowflake {}
public record ReceivedMessage(long clientId, String message) implements EventWithoutSnowflake {}
/**
* Triggers changing connection to a new address.
* Request to change a client connection to a new server.
*
* @param connectionId The identifier of the connection being changed.
* @param ip The new IP address.
* @param port The new port.
* @param clientId The client connection ID.
* @param ip The new server IP.
* @param port The new server port.
*/
public record ChangeClient(Object connectionId, String ip, int port) implements EventWithoutSnowflake {}
public record ChangeClientHost(long clientId, String ip, int port)
implements EventWithoutSnowflake {}
/** WIP (Not working) Response indicating that the client could not connect. */
public record CouldNotConnect(long clientId) implements EventWithoutSnowflake {}
/**
* Triggers when the server couldn't connect to the desired address.
*
* @param connectionId The identifier of the connection that failed.
*/
public record CouldNotConnect(Object connectionId) implements EventWithoutSnowflake {}
/** WIP Triggers when a connection closes. */
public record ClosedConnection() implements EventWithoutSnowflake {}
/** Event indicating a client connection was closed. */
public record ClosedConnection(long clientId) implements EventWithoutSnowflake {}
}

View File

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

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

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;
//
//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()");
// }
package org.toop.framework.eventbus;
// @Test
// void testSetReplacesBus() {
// MBassadorMock<IEvent> mockBus = new MBassadorMock<>();
// GlobalEventBus.set(mockBus);
//
// TestEvent event = new TestEvent("test");
// GlobalEventBus.post(event);
//
// assertEquals(event, mockBus.lastPosted, "Custom bus should receive the event");
// }
//
// // Minimal fake MBassador for verifying set()
// static class MBassadorMock<T extends IEvent> extends net.engio.mbassy.bus.MBassador<T> {
// T lastPosted;
//
// @Override
// public SyncAsyncPostCommand<T> post(T message) {
// this.lastPosted = message;
// return super.post(message);
// }
// }
//}
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.junit.jupiter.api.*;
import org.toop.framework.eventbus.events.EventType;
import org.toop.framework.eventbus.events.EventWithSnowflake;
class GlobalEventBusTest {
// ------------------------------------------------------------------------
// Test Events
// ------------------------------------------------------------------------
private record TestEvent(String message) implements EventType {}
private record TestSnowflakeEvent(long eventSnowflake, String payload)
implements EventWithSnowflake {
@Override
public java.util.Map<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>
<dependencies>
<dependency>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
@@ -110,6 +116,41 @@
<!-- <fork>true</fork>-->
</configuration>
</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>
</build>

View File

@@ -44,11 +44,13 @@ public final class TicTacToe extends Game {
return State.WIN;
}
if (movesLeft <= 0) {
nextPlayer();
if (movesLeft <= 2) {
if (checkDraw(new TicTacToe(this))) {
return State.DRAW;
}
nextPlayer();
}
return State.NORMAL;
}
@@ -81,4 +83,26 @@ public final class TicTacToe extends Game {
// F-Slash
return board[2] != EMPTY && board[2] == board[4] && board[2] == board[6];
}
public boolean checkDraw(TicTacToe game) {
if (game.checkForWin()) {
return false;
}
if (game.movesLeft == 0) {
return true;
}
// try every move on a legal copy
for (Move move : game.getLegalMoves()) {
TicTacToe copy = new TicTacToe(game);
State result = copy.play(move);
if (result == State.WIN) {
return false;
}
if (!checkDraw(copy)) {
return false;
}
}
return true;
}
}

57
pom.xml
View File

@@ -83,13 +83,6 @@
<version>2.0.17</version>
</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 -->
<dependency>
<groupId>com.google.errorprone</groupId>
@@ -100,6 +93,21 @@
</dependencyManagement>
<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>
<plugins>
<plugin>
@@ -162,41 +170,6 @@
</execution>
</executions>
</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>
</pluginManagement>
</build>