mirror of
https://github.com/2OOP/pism.git
synced 2026-02-04 19:04:49 +00:00
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:
6
.github/workflows/checks.yaml
vendored
6
.github/workflows/checks.yaml
vendored
@@ -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
8
.gitignore
vendored
@@ -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
1
.idea/compiler.xml
generated
@@ -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>
|
||||
|
||||
3
.idea/dictionaries/project.xml
generated
3
.idea/dictionaries/project.xml
generated
@@ -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>
|
||||
|
||||
8
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
8
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
2
.idea/misc.xml
generated
@@ -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
15
.idea/resourceBundles.xml
generated
Normal 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>
|
||||
42
app/pom.xml
42
app/pom.xml
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
14
app/src/main/java/org/toop/local/AppContext.java
Normal file
14
app/src/main/java/org/toop/local/AppContext.java
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/resources/assets/audio/fx/dramatic.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/dramatic.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/hitsound0.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/hitsound0.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/hitsound1.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/hitsound1.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/mainmenu.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/mainmenu.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/sadtrombone.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/sadtrombone.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/scawymusic.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/scawymusic.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/suspensful.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/suspensful.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/testsound.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/testsound.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/fx/winsound.wav
Normal file
BIN
app/src/main/resources/assets/audio/fx/winsound.wav
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/music/damned.mp3
Normal file
BIN
app/src/main/resources/assets/audio/music/damned.mp3
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/music/extraction-point.mp3
Normal file
BIN
app/src/main/resources/assets/audio/music/extraction-point.mp3
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/music/godfrey.mp3
Normal file
BIN
app/src/main/resources/assets/audio/music/godfrey.mp3
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/audio/music/mw2-main-menu.mp3
Normal file
BIN
app/src/main/resources/assets/audio/music/mw2-main-menu.mp3
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/fonts/GroovyManiac.ttf
Normal file
BIN
app/src/main/resources/assets/fonts/GroovyManiac.ttf
Normal file
Binary file not shown.
BIN
app/src/main/resources/assets/fonts/Roboto-Regular.ttf
Normal file
BIN
app/src/main/resources/assets/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 357 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ?
|
||||
1
app/src/main/resources/assets/text/test.txt
Normal file
1
app/src/main/resources/assets/text/test.txt
Normal file
@@ -0,0 +1 @@
|
||||
Super gaaf!
|
||||
@@ -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>--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>-->
|
||||
<!-- <!– 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. –>-->
|
||||
<!-- </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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
29
framework/src/main/java/org/toop/framework/asset/Asset.java
Normal file
29
framework/src/main/java/org/toop/framework/asset/Asset.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) : "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
// }
|
||||
// }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
// }
|
||||
//}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
41
game/pom.xml
41
game/pom.xml
@@ -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>
|
||||
|
||||
|
||||
@@ -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
57
pom.xml
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user