Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 445728f636 | |||
| 4523083b20 | |||
| 6ccdebb241 | |||
| 48444dce7f | |||
| 687b746381 | |||
| 974ac28f35 | |||
| 3f8693c929 | |||
| c5806e4f78 | |||
| 9f1a1c3d06 | |||
| 17d9e18dcd | |||
| 7f139f631e | |||
| 401f00de13 | |||
| dfb41a79b6 | |||
| 1764eb9bed | |||
| 0ef5e1a233 | |||
| bbffb7fa60 | |||
| 48de882f70 | |||
| ce3811f8ec | |||
| fd6ffecdf5 | |||
| 5b7939cbd8 | |||
| 0077ea8b4d | |||
| 3e24b7840c | |||
| bf9ead329a | |||
| f270ed8f86 | |||
| 881d5afacd | |||
| 74cdb177e9 | |||
| fa17ea0ca5 | |||
| bbecae5e0d | |||
| 4fb521f54e | |||
| 4e2be3cc9a | |||
| 27978c3782 | |||
| 0ad980a06b | |||
| 0584b01de3 | |||
| 01df07d6af | |||
| 58ecd667b0 | |||
| 19f31082ea | |||
| b605874bea | |||
| 5854ac3d07 | |||
| f770f99a4a | |||
| 76a955bd3d | |||
| e5a4d7fe44 | |||
| d8e7951a04 | |||
| c806490212 | |||
| a36b666d22 | |||
| 4f677dbc77 | |||
| e4b657d0d3 | |||
| 1835464bb3 | |||
| bded27c944 | |||
| 51371c1b24 | |||
| ba718ce4ec | |||
| 778da67e22 | |||
| a9620ef84c | |||
| 45c31d4721 | |||
| 54121215b4 | |||
| fca477eb23 | |||
| ff1ec09d68 | |||
| 3e1153eb8c | |||
| 7f0635ec9d | |||
| 86b8af3b61 | |||
| 023825b89b | |||
| f433e99a12 | |||
| c4b68b8a60 | |||
| c7ccf57723 | |||
| a6a59222d6 | |||
| 6f37fea9ed | |||
| d16648c34c | |||
| 54079accb5 | |||
| 74be610697 | |||
| 0ef2d3872b | |||
| 930b005481 | |||
| cfa9a1ce21 | |||
| 090cc67c06 | |||
| f2eb58c712 | |||
| ec93eaebb2 | |||
| 130150a751 | |||
| 8bfeb4b9f8 | |||
| aa9c68e4c7 | |||
| 504cc02712 | |||
| a8ab312a3a | |||
| 2a0cf4dd2b | |||
| 5f75c3ad1a | |||
| 514425383b | |||
| 13d089a5db | |||
| 4b71d87ca3 | |||
| ffedb4f16e | |||
| 63dcb2af4e | |||
| df4a06fac3 | |||
| de63a8c57b |
@@ -1,6 +1,10 @@
|
|||||||
# file: .cargo/config.toml
|
# file: .cargo/config.toml
|
||||||
# version: 1
|
# version: 2
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
target-dir = "../builds/sasedev-games/target" # path of where to place generated artifacts
|
target-dir = "../builds/sasedev-games/target" # path of where to place generated artifacts
|
||||||
build-dir = "../builds/sasedev-games/target" # path of where to place intermediate build artifacts
|
build-dir = "../builds/sasedev-games/target" # path of where to place intermediate build artifacts
|
||||||
|
|
||||||
|
[target.wasm32-unknown-unknown]
|
||||||
|
rustflags = ["--cfg=web_sys_unstable_apis"]
|
||||||
|
rustdocflags = ["--cfg=web_sys_unstable_apis"]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: Android/README.md -->
|
<!-- file: Android/README.md -->
|
||||||
<!-- version: 8 -->
|
<!-- version: 14 -->
|
||||||
|
|
||||||
# Android
|
# Android
|
||||||
|
|
||||||
Le frontend Android est un projet Gradle multi-module séparé du workspace Cargo.
|
Le frontend Android natif est un projet Gradle multi-module séparé du workspace Cargo.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
@@ -20,23 +20,47 @@ Chaque module `game-*` est une application Android indépendante ; il dépend du
|
|||||||
|
|
||||||
## Toolchain de référence
|
## Toolchain de référence
|
||||||
|
|
||||||
Pour la baseline `0.1.0-0-pre.9` :
|
Le pipeline Android natif `0.3.3` utilise :
|
||||||
|
|
||||||
- Android Gradle Plugin `9.4.0` ;
|
- Android Gradle Plugin `9.4.0` ;
|
||||||
- Gradle `9.6.0` attendu par AGP 9.4 ;
|
- Gradle `>= 9.6.0`, résolu depuis l'environnement de développement ;
|
||||||
- JDK `17` ;
|
- JDK `>= 17` pour exécuter Gradle/AGP, sans `JAVA_HOME` imposé par le projet ;
|
||||||
|
- niveau Java source/target `17` pour la glue Android ;
|
||||||
- `compileSdk 36` ;
|
- `compileSdk 36` ;
|
||||||
- `targetSdk 36` ;
|
- `targetSdk 36` ;
|
||||||
- `minSdk 21` ;
|
- `minSdk 21` ;
|
||||||
|
- NDK `28.2.13676358` (`r28c`) ;
|
||||||
- SDL3 Android AAR `3.4.16`.
|
- SDL3 Android AAR `3.4.16`.
|
||||||
|
|
||||||
Le NDK n'est pas encore utilisé directement dans ce delta. Le prochain jalon introduira la compilation Rust Android `cdylib` et figera la configuration NDK réellement nécessaire.
|
`Android/settings.gradle` déclare `minimumGradleVersion = 9.6.0` et refuse explicitement une version plus ancienne. Une version système plus récente reste autorisée et devient la version réellement utilisée pour le build.
|
||||||
|
|
||||||
|
Le JDK n'est pas épinglé par le projet Android natif. Le shell/IDE fournit le JDK courant ; la gate doit afficher `java -version` et `gradle --version` afin de consigner la combinaison réellement validée. Le POC Tauri Android historique conserve ses propres contraintes de JDK et de wrapper sous sa crate ; elles ne pilotent pas le chemin SDL3 natif.
|
||||||
|
|
||||||
|
## Commandes Gradle
|
||||||
|
|
||||||
|
Depuis la racine du dépôt :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -version
|
||||||
|
(cd Android && gradle --version)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build natif normal utilise directement le Gradle de l'environnement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd Android && gradle :game-snake-poc:assembleDebug)
|
||||||
|
(cd Android && gradle :game-reflex-poc:assembleDebug)
|
||||||
|
(cd Android && gradle :game-snake-poc:bundleRelease)
|
||||||
|
(cd Android && gradle :game-reflex-poc:bundleRelease)
|
||||||
|
```
|
||||||
|
|
||||||
|
`bundleRelease` ne requiert aucun secret dans le dépôt. Sans configuration de signature externe, il produit un AAB de validation non destiné à être téléversé tel quel sur un store.
|
||||||
|
|
||||||
|
Le dépôt ne versionne pas de Gradle Wrapper pour le projet Android natif. Le minimum est un contrat de compatibilité, pas une version exacte imposée à toutes les machines.
|
||||||
|
|
||||||
## SDL3 AAR
|
## SDL3 AAR
|
||||||
|
|
||||||
L'archive n'est pas vendorizée dans le dépôt.
|
L'archive n'est pas vendorizée dans le dépôt. Placer :
|
||||||
|
|
||||||
Placer :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SDL3-3.4.16.aar
|
SDL3-3.4.16.aar
|
||||||
@@ -48,32 +72,48 @@ dans :
|
|||||||
Android/libs/
|
Android/libs/
|
||||||
```
|
```
|
||||||
|
|
||||||
Le module `common` consomme cet artefact et `SaseGameActivity` étend désormais `org.libsdl.app.SDLActivity`.
|
Le module `common` compile contre cet artefact et `SaseGameActivity` étend `org.libsdl.app.SDLActivity`.
|
||||||
|
|
||||||
## Limite volontaire de ce jalon
|
|
||||||
|
|
||||||
Les APK peuvent être compilés côté Java/SDL3 une fois l'AAR disponible, mais ne disposent pas encore du `cdylib` Rust contenant le point d'entrée de jeu.
|
|
||||||
|
|
||||||
Le lancement fonctionnel sur appareil/émulateur appartient donc au delta suivant.
|
|
||||||
|
|
||||||
## Rust Android natif
|
## Rust Android natif
|
||||||
|
|
||||||
Le crate `game-android-entrypoint` produit `libgame_android_entrypoint.so`. Il est compilé séparément pour chaque jeu avec une feature Cargo et copié dans le `jniLibs` du module Android concerné.
|
Le crate `game-android-entrypoint` produit `libgame_android_entrypoint.so`. `Android/gradle/sasedev-rust-android.gradle` transfère au graphe Gradle les responsabilités historiques d'extraction SDL3, de résolution NDK, de `cargo ndk`, de sélection de feature et de staging `jniLibs`.
|
||||||
|
|
||||||
Pour `arm64-v8a` :
|
À `0.3.3-0-pre.5`, Snake et Reflex utilisent tous deux le même pipeline commun avec les quatre ABI Android encore supportées par la toolchain retenue :
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
rustup target add aarch64-linux-android
|
arm64-v8a
|
||||||
cargo install cargo-ndk
|
armeabi-v7a
|
||||||
python3 ../scripts/build_android_rust.py reflex
|
x86_64
|
||||||
python3 ../scripts/build_android_rust.py snake
|
x86
|
||||||
```
|
```
|
||||||
|
|
||||||
Le script extrait temporairement `libSDL3.so` de l'AAR uniquement pour fournir le chemin de linkage à `rustc`; l'AAR reste responsable du packaging SDL3 dans l'APK.
|
Les ABI historiques supprimées des toolchains Android modernes, notamment `armeabi`, `mips` et `mips64`, ne font pas partie du contrat. Le support maximal signifie ici toutes les ABI encore supportées simultanément par Android NDK/SDL3/Rust, et non des architectures abandonnées par l'écosystème.
|
||||||
|
|
||||||
|
Chaque variante Android déclenche, pour chaque ABI, un build `cargo ndk` avec exactement la feature du module concerné. La variante Debug utilise le profil Cargo `dev`; la variante Release ajoute `--release`. Les outputs sont placés sous :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Android/<game>/build/generated/sasedevNative/<variant>/<abi>/jniLibs/<abi>/
|
||||||
|
├── libSDL3.so
|
||||||
|
└── libgame_android_entrypoint.so
|
||||||
|
```
|
||||||
|
|
||||||
|
AGP fusionne ces sources `jniLibs` générées dans l'APK Debug universal ou dans l'AAB Release. Aucune copie durable n'est faite dans `src/main/jniLibs`.
|
||||||
|
|
||||||
|
Le builder historique `scripts/build_android_rust.py` est supprimé en `0-pre.5` : toutes ses responsabilités durables (résolution NDK, extraction SDL3, linkage, sélection de feature, `cargo ndk`, profil release et staging) appartiennent désormais au graphe Gradle.
|
||||||
|
|
||||||
|
## Compatibilité 16 KB et minSdk
|
||||||
|
|
||||||
|
Le plancher Android reste `minSdk 21` / Android 5.0, qui correspond au minimum Android documenté par SDL3. Le niveau Rust transmis à `cargo ndk` reste également `21`.
|
||||||
|
|
||||||
|
La baseline utilise AGP `9.4.0` et NDK `r28c`. Pour la contrainte Google Play 16 KB sur appareils 64 bits, les bibliothèques `arm64-v8a` et `x86_64` doivent être compatibles 16 KB ; SDL3 étant fourni sous forme précompilée, `libSDL3.so` est vérifiée au même titre que `libgame_android_entrypoint.so`. La gate `0-pre.5` a confirmé des segments ELF `LOAD` alignés au moins à `2**14` pour ces deux ABI, puis un `zipalign -P 16` réussi sur les APK.
|
||||||
|
|
||||||
|
Les ABI 32 bits `armeabi-v7a` et `x86` restent supportées pour compatibilité matérielle ancienne ; leurs segments observés à `2**12` ne contredisent pas l'exigence Play 16 KB, qui s'applique aux appareils 64 bits. L'AAB doit néanmoins contenir les deux bibliothèques pour les quatre ABI sous `base/lib/<abi>/`. Si `bundletool` est disponible dans l'environnement de validation, `bundletool dump config --bundle=<aab>` peut compléter le contrôle du bundle.
|
||||||
|
|
||||||
|
La preuve runtime `0-pre.5` couvre également Android 5.0/API 21 sur x86 et Android 15/API 35 x86_64 avec `getconf PAGE_SIZE=16384` sur une image `ps16k`.
|
||||||
|
|
||||||
## Bridge JNI et tactile
|
## Bridge JNI et tactile
|
||||||
|
|
||||||
À partir de `0.1.0-0-pre.9`, `SaseGameActivity` vérifie au démarrage la version du bridge Java/JNI chargé depuis la bibliothèque Rust.
|
`SaseGameActivity` vérifie au démarrage la version du bridge Java/JNI chargé depuis la bibliothèque Rust.
|
||||||
|
|
||||||
Les touch events standards restent traités via SDL3 et ne transitent pas par JNI.
|
Les touch events standards restent traités via SDL3 et ne transitent pas par JNI.
|
||||||
|
|
||||||
@@ -96,6 +136,6 @@ Aucune ressource source n'est copiée durablement dans un module Android ou une
|
|||||||
|
|
||||||
`Android/gradle.properties` définit `androidNdkVersion=28.2.13676358`.
|
`Android/gradle.properties` définit `androidNdkVersion=28.2.13676358`.
|
||||||
|
|
||||||
Gradle utilise cette valeur comme `ndkVersion`. Le script Rust Android résout la même version sous `${ANDROID_HOME}/ndk/` et définit `ANDROID_NDK_HOME` uniquement pour le sous-processus `cargo ndk`.
|
Gradle utilise cette valeur comme `ndkVersion`. La tâche Rust Android résout la même version sous `${ANDROID_HOME}/ndk/` ou `${ANDROID_SDK_ROOT}/ndk/` et définit `ANDROID_NDK_HOME` uniquement pour le sous-processus `cargo ndk`.
|
||||||
|
|
||||||
Aucun `ANDROID_NDK_HOME` global n'est requis ; plusieurs NDK peuvent rester installés côte à côte.
|
Aucun `ANDROID_NDK_HOME` global n'est requis ; plusieurs NDK peuvent rester installés côte à côte.
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// file: Android/game-reflex-poc/build.gradle
|
// file: Android/game-reflex-poc/build.gradle
|
||||||
// version: 24
|
// version: 47
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext.sasedevRustGameFeature = "reflex"
|
||||||
|
ext.sasedevRustAndroidAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"]
|
||||||
|
ext.sasedevRustAndroidApi = 21
|
||||||
|
|
||||||
def sdl3AarName = providers.gradleProperty("sdl3AarName").get()
|
def sdl3AarName = providers.gradleProperty("sdl3AarName").get()
|
||||||
def androidNdkVersion = providers.gradleProperty("androidNdkVersion").get()
|
def androidNdkVersion = providers.gradleProperty("androidNdkVersion").get()
|
||||||
|
|
||||||
@@ -17,8 +21,12 @@ android {
|
|||||||
applicationId 'com.sasedev.games.reflex'
|
applicationId 'com.sasedev.games.reflex'
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName '0.1.0'
|
versionName '0.2.0'
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -34,3 +42,4 @@ dependencies {
|
|||||||
|
|
||||||
ext.sasedevGameAssetDirectory = "game-reflex-poc"
|
ext.sasedevGameAssetDirectory = "game-reflex-poc"
|
||||||
apply from: rootProject.file("gradle/sasedev-assets.gradle")
|
apply from: rootProject.file("gradle/sasedev-assets.gradle")
|
||||||
|
apply from: rootProject.file("gradle/sasedev-rust-android.gradle")
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// file: Android/game-snake-poc/build.gradle
|
// file: Android/game-snake-poc/build.gradle
|
||||||
// version: 24
|
// version: 48
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext.sasedevRustGameFeature = "snake"
|
||||||
|
ext.sasedevRustAndroidAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"]
|
||||||
|
ext.sasedevRustAndroidApi = 21
|
||||||
|
|
||||||
def sdl3AarName = providers.gradleProperty("sdl3AarName").get()
|
def sdl3AarName = providers.gradleProperty("sdl3AarName").get()
|
||||||
def androidNdkVersion = providers.gradleProperty("androidNdkVersion").get()
|
def androidNdkVersion = providers.gradleProperty("androidNdkVersion").get()
|
||||||
|
|
||||||
@@ -17,8 +21,12 @@ android {
|
|||||||
applicationId 'com.sasedev.games.snake'
|
applicationId 'com.sasedev.games.snake'
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName '0.1.0'
|
versionName '0.2.0'
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -34,3 +42,4 @@ dependencies {
|
|||||||
|
|
||||||
ext.sasedevGameAssetDirectory = "game-snake-poc"
|
ext.sasedevGameAssetDirectory = "game-snake-poc"
|
||||||
apply from: rootProject.file("gradle/sasedev-assets.gradle")
|
apply from: rootProject.file("gradle/sasedev-assets.gradle")
|
||||||
|
apply from: rootProject.file("gradle/sasedev-rust-android.gradle")
|
||||||
|
|||||||
204
Android/gradle/sasedev-rust-android.gradle
Normal file
204
Android/gradle/sasedev-rust-android.gradle
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// file: Android/gradle/sasedev-rust-android.gradle
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.gradle.process.ExecOperations
|
||||||
|
|
||||||
|
abstract class BuildSasedevRustAndroidTask extends DefaultTask {
|
||||||
|
@InputFile
|
||||||
|
@PathSensitive(PathSensitivity.RELATIVE)
|
||||||
|
abstract RegularFileProperty getSdl3Aar()
|
||||||
|
|
||||||
|
@InputFiles
|
||||||
|
@PathSensitive(PathSensitivity.RELATIVE)
|
||||||
|
abstract ConfigurableFileCollection getRustInputs()
|
||||||
|
|
||||||
|
@Input
|
||||||
|
abstract Property<String> getGameFeature()
|
||||||
|
|
||||||
|
@Input
|
||||||
|
abstract Property<String> getAbi()
|
||||||
|
|
||||||
|
@Input
|
||||||
|
abstract Property<Integer> getAndroidApi()
|
||||||
|
|
||||||
|
@Input
|
||||||
|
abstract Property<String> getNdkVersion()
|
||||||
|
|
||||||
|
@Input
|
||||||
|
abstract Property<Boolean> getReleaseBuild()
|
||||||
|
|
||||||
|
@Internal
|
||||||
|
abstract DirectoryProperty getRepositoryRoot()
|
||||||
|
|
||||||
|
@OutputDirectory
|
||||||
|
abstract DirectoryProperty getOutputDirectory()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
abstract ExecOperations getExecOperations()
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
void buildNative() {
|
||||||
|
def selectedAbi = abi.get()
|
||||||
|
def selectedFeature = gameFeature.get()
|
||||||
|
def aar = sdl3Aar.get().asFile
|
||||||
|
def repository = repositoryRoot.get().asFile
|
||||||
|
def output = outputDirectory.get().asFile
|
||||||
|
def runtimeDirectory = new File(output, selectedAbi)
|
||||||
|
def linkDirectory = new File(temporaryDir, "link")
|
||||||
|
|
||||||
|
project.delete(output)
|
||||||
|
project.delete(linkDirectory)
|
||||||
|
runtimeDirectory.mkdirs()
|
||||||
|
linkDirectory.mkdirs()
|
||||||
|
|
||||||
|
byte[] sdl3Payload
|
||||||
|
new ZipFile(aar).withCloseable { archive ->
|
||||||
|
def candidates = [
|
||||||
|
"prefab/modules/SDL3/libs/android.${selectedAbi}/libSDL3.so",
|
||||||
|
"jni/${selectedAbi}/libSDL3.so",
|
||||||
|
]
|
||||||
|
def entry = candidates.collect { candidate -> archive.getEntry(candidate) }.find { candidate -> candidate != null }
|
||||||
|
if (entry == null) {
|
||||||
|
def suffix = "/libs/android.${selectedAbi}/libSDL3.so"
|
||||||
|
def fallback = Collections.list(archive.entries()).findAll { candidate ->
|
||||||
|
!candidate.directory && candidate.name.endsWith(suffix)
|
||||||
|
}
|
||||||
|
if (fallback.size() != 1) {
|
||||||
|
throw new GradleException(
|
||||||
|
"SDL3 shared library for ABI ${selectedAbi} is missing from ${aar}; " +
|
||||||
|
"expected Prefab member ${candidates[0]}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry = fallback[0]
|
||||||
|
}
|
||||||
|
sdl3Payload = archive.getInputStream(entry).withCloseable { stream -> stream.bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
def linkLibrary = new File(linkDirectory, "libSDL3.so")
|
||||||
|
def runtimeLibrary = new File(runtimeDirectory, "libSDL3.so")
|
||||||
|
linkLibrary.bytes = sdl3Payload
|
||||||
|
runtimeLibrary.bytes = sdl3Payload
|
||||||
|
|
||||||
|
def androidHome = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT")
|
||||||
|
if (!androidHome) {
|
||||||
|
throw new GradleException("ANDROID_HOME or ANDROID_SDK_ROOT is required to resolve the project NDK")
|
||||||
|
}
|
||||||
|
def configuredNdk = new File(androidHome, "ndk/${ndkVersion.get()}")
|
||||||
|
if (!configuredNdk.isDirectory()) {
|
||||||
|
throw new GradleException("configured Android NDK is missing: ${configuredNdk}")
|
||||||
|
}
|
||||||
|
|
||||||
|
def inheritedRustFlags = System.getenv("RUSTFLAGS")?.trim()
|
||||||
|
def linkFlag = "-Lnative=${linkDirectory}"
|
||||||
|
def rustFlags = inheritedRustFlags ? "${inheritedRustFlags} ${linkFlag}" : linkFlag
|
||||||
|
|
||||||
|
def cargoCommand = [
|
||||||
|
"cargo",
|
||||||
|
"ndk",
|
||||||
|
"-t",
|
||||||
|
selectedAbi,
|
||||||
|
"-o",
|
||||||
|
output.absolutePath,
|
||||||
|
"build",
|
||||||
|
"-p",
|
||||||
|
"game-android-entrypoint",
|
||||||
|
]
|
||||||
|
if (releaseBuild.get()) {
|
||||||
|
cargoCommand.add("--release")
|
||||||
|
}
|
||||||
|
cargoCommand.addAll([
|
||||||
|
"--no-default-features",
|
||||||
|
"--features",
|
||||||
|
selectedFeature,
|
||||||
|
])
|
||||||
|
|
||||||
|
execOperations.exec { spec ->
|
||||||
|
spec.workingDir(repository)
|
||||||
|
spec.environment("ANDROID_NDK_HOME", configuredNdk.absolutePath)
|
||||||
|
spec.environment("CARGO_NDK_PLATFORM", androidApi.get().toString())
|
||||||
|
spec.environment("RUSTFLAGS", rustFlags)
|
||||||
|
spec.commandLine(cargoCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
def rustLibrary = new File(runtimeDirectory, "libgame_android_entrypoint.so")
|
||||||
|
if (!runtimeLibrary.isFile()) {
|
||||||
|
throw new GradleException("missing staged SDL3 Android library: ${runtimeLibrary}")
|
||||||
|
}
|
||||||
|
if (!rustLibrary.isFile()) {
|
||||||
|
throw new GradleException("missing Rust Android library after cargo-ndk build: ${rustLibrary}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.ext.has("sasedevRustGameFeature")) {
|
||||||
|
throw new GradleException("sasedevRustGameFeature must select exactly one Cargo game feature")
|
||||||
|
}
|
||||||
|
if (!project.ext.has("sasedevRustAndroidAbis")) {
|
||||||
|
throw new GradleException("sasedevRustAndroidAbis must declare at least one Android ABI")
|
||||||
|
}
|
||||||
|
if (!project.ext.has("sasedevRustAndroidApi")) {
|
||||||
|
throw new GradleException("sasedevRustAndroidApi must declare the Rust Android API level")
|
||||||
|
}
|
||||||
|
|
||||||
|
def selectedGameFeature = project.ext.sasedevRustGameFeature as String
|
||||||
|
def selectedAbis = (project.ext.sasedevRustAndroidAbis as List).collect { configuredAbi -> configuredAbi as String }
|
||||||
|
def selectedAndroidApi = project.ext.sasedevRustAndroidApi as Integer
|
||||||
|
def configuredSdl3AarName = providers.gradleProperty("sdl3AarName").get()
|
||||||
|
def configuredNdkVersion = providers.gradleProperty("androidNdkVersion").get()
|
||||||
|
def repositoryDirectory = rootProject.projectDir.parentFile
|
||||||
|
|
||||||
|
def supportedAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"] as Set
|
||||||
|
if (selectedAbis.isEmpty()) {
|
||||||
|
throw new GradleException("sasedevRustAndroidAbis must not be empty")
|
||||||
|
}
|
||||||
|
if (selectedAbis.toSet().size() != selectedAbis.size()) {
|
||||||
|
throw new GradleException("sasedevRustAndroidAbis must not contain duplicates: ${selectedAbis}")
|
||||||
|
}
|
||||||
|
def unsupportedAbis = selectedAbis.findAll { configuredAbi -> !supportedAbis.contains(configuredAbi) }
|
||||||
|
if (!unsupportedAbis.isEmpty()) {
|
||||||
|
throw new GradleException("unsupported Rust Android ABI(s): ${unsupportedAbis.join(', ')}")
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().all()) { variant ->
|
||||||
|
def selectedReleaseBuild = variant.buildType == "release"
|
||||||
|
selectedAbis.each { selectedAbi ->
|
||||||
|
def variantSuffix = variant.name.substring(0, 1).toUpperCase() + variant.name.substring(1)
|
||||||
|
def abiSuffix = selectedAbi
|
||||||
|
.split(/[^A-Za-z0-9]+/)
|
||||||
|
.findAll { segment -> !segment.isEmpty() }
|
||||||
|
.collect { segment -> segment.substring(0, 1).toUpperCase() + segment.substring(1) }
|
||||||
|
.join("")
|
||||||
|
def nativeTask = tasks.register(
|
||||||
|
"build${variantSuffix}SasedevRust${abiSuffix}",
|
||||||
|
BuildSasedevRustAndroidTask,
|
||||||
|
) {
|
||||||
|
sdl3Aar.set(rootProject.layout.projectDirectory.file("libs/${configuredSdl3AarName}"))
|
||||||
|
rustInputs.from(rootProject.file("../Cargo.toml"))
|
||||||
|
rustInputs.from(rootProject.file("../.cargo/config.toml"))
|
||||||
|
rustInputs.from(rootProject.fileTree("../crates") {
|
||||||
|
include "**/*.rs"
|
||||||
|
include "**/Cargo.toml"
|
||||||
|
include "**/build.rs"
|
||||||
|
})
|
||||||
|
gameFeature.set(selectedGameFeature)
|
||||||
|
abi.set(selectedAbi)
|
||||||
|
androidApi.set(selectedAndroidApi)
|
||||||
|
ndkVersion.set(configuredNdkVersion)
|
||||||
|
releaseBuild.set(selectedReleaseBuild)
|
||||||
|
repositoryRoot.set(repositoryDirectory)
|
||||||
|
outputDirectory.set(layout.buildDirectory.dir("generated/sasedevNative/${variant.name}/${selectedAbi}/jniLibs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant.sources.jniLibs == null) {
|
||||||
|
throw new GradleException("AGP variant ${variant.name} does not expose jniLibs sources")
|
||||||
|
}
|
||||||
|
variant.sources.jniLibs.addGeneratedSourceDirectory(nativeTask) { task ->
|
||||||
|
task.outputDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// file: Android/settings.gradle
|
// file: Android/settings.gradle
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
|
import org.gradle.util.GradleVersion
|
||||||
|
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
@@ -9,6 +11,14 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def minimumGradleVersion = GradleVersion.version("9.6.0")
|
||||||
|
def currentGradleVersion = GradleVersion.current()
|
||||||
|
if (currentGradleVersion < minimumGradleVersion) {
|
||||||
|
throw new GradleException(
|
||||||
|
"Gradle ${minimumGradleVersion.version} or newer is required; current version is ${currentGradleVersion.version}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
185
CHANGELOG.md
185
CHANGELOG.md
@@ -1,16 +1,138 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 8 -->
|
<!-- version: 22 -->
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.5 — 2026-09-22
|
||||||
|
|
||||||
|
- publication stable du second backend realtime `game-realtime-webtransport-lib`, retenu comme implémentation WebTransport/QUIC supplémentaire tandis que `game-realtime-websocket-lib` reste la baseline et le fallback de référence ;
|
||||||
|
- établissement natif QUIC/HTTP3 avec TLS et pin SHA-256 exact, stream bidirectionnel fiable compatible `RealtimeConnection`, deadlines/cancellation bornées et client navigateur/WASM WebTransport réel validé contre le serveur Rust natif ;
|
||||||
|
- composition WebTransport-first validée sans manager générique : fallback WebSocket limité aux erreurs classifiées `Timeout`/`Io`, avec les erreurs ambiguës ou de configuration laissées visibles ;
|
||||||
|
- datagrams WebTransport conservés comme capacité backend-spécifique non fiable/non ordonnée, avec taille maximale négociée et sans extension du contrat fiable commun ;
|
||||||
|
- caractérisation loopback bornée de l'établissement, du RTT, de plusieurs patterns de throughput et des datagrams, documentée comme observation locale et non comme classement général ni prédiction Internet/mobile ;
|
||||||
|
- `0.3.5-rc.1` validée avec audits propres, `cargo check`, Clippy strict, **77 tests workspace**, quatre smokes natifs `PASS`, mesures release, builds WASM/frontend, smoke navigateur réel `PASS`, cross-compilation Android ARM64/API 21 et graphes de dépendances conformes ;
|
||||||
|
- documentation durable et architectures consolidées ; Android runtime WebTransport et plateformes Apple restent explicitement non validés, et aucun codec wire métier final, protocole session joueur/room, reconnect/resync, synchronisation gameplay, prediction/reconciliation, matchmaking ou simulation authoritative n'est introduit par `0.3.5`.
|
||||||
|
|
||||||
|
Les détails des phases `alpha`, `beta`, `rc` et de leurs correctifs restent dans `deltas/0.3.5/` et `history/0.3.5/`.
|
||||||
|
|
||||||
|
## 0.3.5-rc.1 — 2026-09-22
|
||||||
|
|
||||||
|
- gel de la seconde implémentation realtime autour de `game-realtime-webtransport-lib`, conservée comme backend WebTransport/QUIC supplémentaire tandis que `game-realtime-websocket-lib` reste la baseline et le fallback de référence ;
|
||||||
|
- établissement natif QUIC/HTTP3 avec TLS et pin SHA-256 exact, stream bidirectionnel fiable compatible `RealtimeConnection`, deadlines/cancellation bornées et client navigateur/WASM WebTransport réel validé contre le serveur Rust natif ;
|
||||||
|
- composition WebTransport-first validée sans manager générique : fallback WebSocket limité aux erreurs classifiées `Timeout`/`Io`, avec les erreurs ambiguës ou de configuration laissées visibles ;
|
||||||
|
- datagrams WebTransport conservés comme capacité backend-spécifique, non fiable et non ordonnée, avec taille maximale négociée et sans extension du contrat fiable commun ;
|
||||||
|
- caractérisation loopback bornée de l'établissement, du RTT, de plusieurs patterns de throughput et des datagrams ; ces mesures décrivent les wrappers locaux et ne constituent ni un classement général des transports ni une prédiction Internet/mobile ;
|
||||||
|
- validation large `beta.1` avec 77 tests workspace, smokes natifs WebSocket/WebTransport/datagram/fallback, builds WASM/frontend et smoke navigateur réel ; cross-compilation du backend WebTransport validée pour Android ARM64/API 21 avec NDK `28.2.13676358`, sans prétendre à un smoke runtime Android WebTransport ;
|
||||||
|
- consolidation `beta.2` des README/USAGE et architectures, décision **retained** figée pour WebTransport, et préparation du prompt `0.3.6` pour réconcilier les POC `0.3.x` avant Uroburas `0.4.x` ;
|
||||||
|
- aucun codec wire métier final, protocole session joueur/room, reconnect/resync, synchronisation gameplay, prediction/reconciliation, matchmaking ou simulation authoritative n'est introduit par `0.3.5`.
|
||||||
|
|
||||||
|
La RC n'ouvre aucun nouveau scope. Seuls les correctifs nécessaires à la publication selon `VER-RC-*` peuvent produire `0.3.5-rc.1.fix.N`.
|
||||||
|
|
||||||
|
## 0.3.4 — 2026-09-21
|
||||||
|
|
||||||
|
- publication stable de la première baseline realtime, avec `game-realtime-transport-lib` comme contrat binaire transport-neutral et `game-realtime-websocket-lib` comme backend Tokio/tokio-tungstenite ;
|
||||||
|
- client `ws://`, listener serveur, split send/receive, close propre, limites message/frame/write-buffer, deadlines connect/send/close et erreurs transport-neutral validés sans runtime ni task backend privé ;
|
||||||
|
- robustesse validée par tests loopback et négatifs : payloads hors limite, Text interdit, peer drop abrupt, handshake silencieux borné et mappings capacité/backpressure ;
|
||||||
|
- smoke runtime `game-realtime-websocket-smoke: PASS` validé hors harness de test sur localhost avec port éphémère, échange binaire bidirectionnel et fermeture propre ;
|
||||||
|
- `0.3.4-beta.1` a validé le workspace complet avec 53 tests et l'arbre inverse a confirmé que seul le launcher de smoke dépend du backend WebSocket ; `0.3.4-rc.1` a ensuite repassé audits, check/Clippy, 18 tests realtime ciblés, smoke et frontière de dépendances sans défaut ;
|
||||||
|
- documentation durable des deux crates realtime livrée et prompt `0.3.5` préparé pour évaluer WebTransport/QUIC sur la même frontière avec WebSocket comme baseline/fallback ;
|
||||||
|
- aucun TLS direct, wire codec final, protocole session, synchronisation gameplay, simulation authoritative ou serveur Uroburas n'est introduit dans `0.3.4`.
|
||||||
|
|
||||||
|
Les détails des phases `alpha`, `beta`, `rc` et du correctif `alpha.3.fix.1` restent dans `deltas/0.3.4/` et `history/0.3.4/`.
|
||||||
|
|
||||||
|
## 0.3.4-rc.1 — 2026-09-21
|
||||||
|
|
||||||
|
- gel fonctionnel de la baseline realtime : `game-realtime-transport-lib` porte le contrat binaire transport-neutral et `game-realtime-websocket-lib` son backend Tokio/tokio-tungstenite, sans dépendance transport dans les moteurs ou le gameplay ;
|
||||||
|
- client `ws://`, listener serveur, split send/receive, close propre, erreurs transport-neutral, limites de message/frame/write-buffer et deadlines connect/send/close validés sans runtime ni task backend privé ;
|
||||||
|
- robustesse validée sur localhost : round-trip binaire, payload hors limite, Text interdit, peer drop abrupt, handshake silencieux borné, mappings capacité/backpressure et smoke runtime public `game-realtime-websocket-smoke: PASS` ;
|
||||||
|
- `0.3.4-beta.1` validée avec audits propres, `cargo check`, Clippy strict et `cargo test --workspace --all-targets --all-features` : 53 tests réussis, puis smoke runtime séparé et arbre inverse confirmant que seul le launcher de smoke dépend du backend WebSocket ;
|
||||||
|
- consolidation de la documentation durable des deux crates realtime et préparation du prompt `0.3.5` pour évaluer WebTransport/QUIC sur la même frontière, avec WebSocket conservé comme fallback de référence ;
|
||||||
|
- aucun TLS direct, protocole de session, synchronisation gameplay, simulation authoritative ou serveur Uroburas n'est introduit dans `0.3.4`.
|
||||||
|
|
||||||
|
La RC n'ouvre aucun nouveau scope. Seuls les correctifs nécessaires à la publication selon `VER-RC-*` peuvent produire `0.3.4-rc.1.fix.N`.
|
||||||
|
|
||||||
|
## 0.3.3 — 2026-09-21
|
||||||
|
|
||||||
|
- publication stable du pipeline Android SDL3 natif multi-ABI possédé par Gradle/Cargo, sans orchestrateur Python de build ni `src/main/jniLibs` généré dans les sources ;
|
||||||
|
- APK Debug universal et AAB Release produits pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, avec `minSdk 21` conservé et fumé sur Android 5.0/API 21 x86 ;
|
||||||
|
- compatibilité pages mémoire 16 KB validée pour les ABI 64 bits : `zipalign -P 16`, segments ELF `align 2**14` et smoke API 35 x86_64 `ps16k` avec `PAGE_SIZE=16384` ;
|
||||||
|
- smokes finaux Snake et Reflex validés sur AVD API 36 x86_64 et Samsung API 29 ARM64, avec installation réussie, activité lancée, processus vivant et aucune entrée crash remontée ;
|
||||||
|
- environnement Android natif validé avec JDK courant et Gradle système `>= 9.6.0`, sans wrapper Gradle ni `JAVA_HOME` imposé au projet ;
|
||||||
|
- préparation de `0.3.4` pour l'API de transport realtime/WebSocket et pour la nouvelle nomenclature prospective `alpha.M / beta.M / rc.M`, sans aucune réécriture des anciens `deltas/`, `history/`, prompts ou entrées historiques.
|
||||||
|
|
||||||
|
Les détails des phases `pre`, `beta`, `rc` et de leurs correctifs restent dans `deltas/0.3.3/` et `history/0.3.3/`.
|
||||||
|
|
||||||
|
## 0.3.3-3-rc.1 — 2026-09-21
|
||||||
|
|
||||||
|
- gel fonctionnel du pipeline Android SDL3 natif désormais possédé par Gradle/Cargo, sans orchestrateur Python de build ni `src/main/jniLibs` généré dans les sources ;
|
||||||
|
- APK Debug universal et AAB Release validés pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, avec `minSdk 21` conservé ;
|
||||||
|
- compatibilité pages mémoire 16 KB validée sur les ABI 64 bits, y compris smoke Android 15/API 35 x86_64 `ps16k` avec `PAGE_SIZE=16384` ;
|
||||||
|
- smoke Android 5.0/API 21 x86 validé, puis smokes beta Snake et Reflex validés sur AVD API 36 x86_64 et Samsung API 29 ARM64 sans crash immédiat ;
|
||||||
|
- environnement Android natif validé avec Temurin 25 et Gradle système 9.7.1, le projet imposant seulement `Gradle >= 9.6.0` ;
|
||||||
|
- prompt `0.3.4` préparé pour l'API de transport realtime/WebSocket et pour la migration de nomenclature à `alpha.M`, `beta.M`, `rc.M` à partir de la prochaine session uniquement ; aucun historique antérieur ne sera renommé ou réécrit.
|
||||||
|
|
||||||
|
La RC n'ouvre aucun nouveau scope. Seuls les correctifs de publication autorisés par `VER-RC-*` peuvent produire un `3-rc.1.fix.N`.
|
||||||
|
|
||||||
|
## 0.3.1 — 2026-09-21
|
||||||
|
|
||||||
|
- publication stable du second host Snake sous Tauri Android, conservé comme POC de référence et non comme voie de production Android ;
|
||||||
|
- validation du gameplay partagé via `game-snake-poc-wasm`, du Canvas, des contrôles tactiles, des assets, du lifecycle mobile/WebView, du tracing et de la provenance runtime ;
|
||||||
|
- validation d’un APK Debug universal autonome contenant `arm64-v8a` et `x86_64`, installé et lancé sur Galaxy S9+ ARM64 réel et AVD API 36 x86_64 ;
|
||||||
|
- validation finale de `0.3.1-3-rc.1` sans défaut nécessitant un correctif RC, puis promotion mécanique vers la stable ;
|
||||||
|
- maintien de `0.3.2` en différé et préparation de `0.3.3` pour Android SDL3 natif multi-ABI, APK universal de test, AAB de distribution et évaluation du `minSdk`.
|
||||||
|
|
||||||
|
Les détails des phases `pre`, `beta`, `rc` et de leurs correctifs restent dans `deltas/0.3.1/` et `history/0.3.1/`.
|
||||||
|
|
||||||
|
## 0.3.1-3-rc.1 — 2026-09-21
|
||||||
|
|
||||||
|
- gel fonctionnel du POC Snake Tauri Android de référence après validation beta du workspace complet et du packaging Android autonome ;
|
||||||
|
- APK Debug universal construit avec les cibles Rust `aarch64-linux-android` et `x86_64-linux-android`, puis installé et lancé avec succès sur Galaxy S9+ ARM64 et AVD API 36 x86_64 ;
|
||||||
|
- host Tauri conservé comme POC comparatif : la voie Android productive reste SDL3 natif/Java/JNI ;
|
||||||
|
- absence d’extraction Web/Tauri supplémentaire et absence d’AAB Tauri volontaire : les choix de distribution Android productive sont reportés au POC SDL3 natif ;
|
||||||
|
- `0.3.2` Tauri Desktop reste différée faute de bénéfice produit/monétisation démontré ; la prochaine version active préparée est `0.3.3`, Android SDL3 natif multi-ABI, avec AAB et compatibilité Android ancienne à évaluer.
|
||||||
|
|
||||||
|
La RC n’ouvre aucun nouveau scope. Seuls les correctifs de publication autorisés par `VER-RC-*` peuvent produire un `3-rc.1.fix.N`.
|
||||||
|
|
||||||
|
## 0.3.0 — 2026-09-20
|
||||||
|
|
||||||
|
- publication stable du premier POC Snake Web direct, construit autour du gameplay Rust partagé, d'un adapter WASM dédié et d'un host navigateur Vite/TypeScript ;
|
||||||
|
- validation du shell Bootstrap 5/Bootswatch + SimpleBar, du Canvas au ratio logique `12 × 20`, des contrôles clavier/pointer, du lifecycle navigateur, des assets et de la provenance runtime ;
|
||||||
|
- validation de la non-régression Desktop SDL3 et des builds release Desktop et Web/WASM ;
|
||||||
|
- promotion mécanique de `0.3.0-3-rc.1` après gate workspace complète, 34 tests réussis et absence de défaut nécessitant un correctif RC ;
|
||||||
|
- préparation de `0.3.1` pour le second host Snake Tauri Android, sans ouvrir ce scope dans la release `0.3.0`.
|
||||||
|
|
||||||
|
Les détails des phases `pre`, `beta`, `rc` et de leurs correctifs restent dans `deltas/0.3.0/` et `history/0.3.0/`.
|
||||||
|
|
||||||
|
## 0.3.0-3-rc.1 — 2026-09-20
|
||||||
|
|
||||||
|
- gel fonctionnel du premier POC Snake Web direct après validation beta et consolidation documentaire ;
|
||||||
|
- gameplay Snake conservé dans `game-snake-poc`, avec adapter `game-snake-poc-wasm` dédié et host navigateur Vite/TypeScript séparé ;
|
||||||
|
- shell Web responsive fondé sur Bootstrap 5/Bootswatch, SimpleBar et Canvas au ratio logique du jeu, avec clavier et contrôles pointer/tactiles ;
|
||||||
|
- lifecycle navigateur fixed-step sans rattrapage massif, resize `devicePixelRatio`, assets `common/` + `game/`, logging frontend structuré et provenance `Web / Browser / Wasm` ;
|
||||||
|
- runner Desktop SDL3 maintenu comme témoin de non-régression et builds release Desktop/Web déjà exercés pendant la beta ;
|
||||||
|
- prompt `0.3.1` préparé pour le second host Snake Tauri Android, avec workflow de session, commandes et validations consolidés.
|
||||||
|
|
||||||
|
La RC n'ouvre aucun nouveau scope. Les détails des phases `pre`, `beta` et de leurs correctifs restent dans `deltas/0.3.0/` et `history/0.3.0/`.
|
||||||
|
|
||||||
|
## 0.2.0 — conception modulaire, POC et Uroburas
|
||||||
|
|
||||||
|
- Gouvernance documentaire renforcée : catégories `ideas/studies/architecture/rules`, immutabilité des deltas, conventions de statuts et workflow de session/version.
|
||||||
|
- Architecture durable séparant engine kernel, technical capabilities, game-systems, games, platform adapters, providers, server services et tooling.
|
||||||
|
- Trajectoire POC `0.3.x` définie avec Snake comme jeu-sonde pour SDL, Web/WASM, Tauri et transports realtime.
|
||||||
|
- Architecture réseau de référence : Actix Web/Maud/Fluent/Lettre pour Web/API, WebSocket/tokio-tungstenite baseline, WebTransport/QUIC candidat, gRPC/Tonic conditionnel server-to-server.
|
||||||
|
- Asset delivery auto-hébergé, compatible HTTP/2 et HTTP/3 selon disponibilité, avec séparation logique puis physique progressive.
|
||||||
|
- Cible d'exploitation préférée : Debian Stable, actuellement Debian 13 `trixie`, sans dépendance métier à une version précise.
|
||||||
|
- Spécification fonctionnelle et architecture cible initiales de Uroburas, avec ordre Mode 1 Challenge → Mode 3 Persistent Battle Royale → Mode 2 PvP Battles.
|
||||||
|
- Classification des besoins Uroburas afin d'éviter une crate jeu monolithique.
|
||||||
|
- Première trajectoire Uroburas réservant grid/tilemap toroïdale, obstacles, stages, vies, score, caméra, assets téléchargeables, auth, rewarded ads, Hall of Fame et échanges client/serveur sécurisés/versionnés.
|
||||||
|
|
||||||
## 0.1.0 — 2026-09-17
|
## 0.1.0 — 2026-09-17
|
||||||
|
|
||||||
- validation de `0.1.0-3-rc.1.fix.1` avec gates Rust/audits complètes, builds et smokes Desktop release, Tauri/WASM, Android x86_64/API 36 et Android ARM64 réel ;
|
- validation de `0.1.0-3-rc.1.fix.1` avec gates Rust/audits complètes, builds et smokes Desktop release, Tauri/WASM, Android x86_64/API 36 et Android ARM64 réel ;
|
||||||
- stabilisation d'une première baseline POC multi-plateforme fondée sur un core de gameplay Rust réutilisé par des runners SDL3 Desktop/Android et un POC Tauri/WebView/WASM ;
|
- stabilisation d'une première baseline POC multi-plateforme fondée sur un core de gameplay Rust réutilisé par des runners SDL3 Desktop/Android et un POC Tauri/WebView/WASM ;
|
||||||
- validation de l'abstraction d'input, du rendu 2D minimal, des assets, de la provenance runtime, du tracing commun et du lifecycle/Back Android ;
|
- validation de l'abstraction d'input, du rendu 2D minimal, des assets, de la provenance runtime, du tracing commun et du lifecycle/Back Android ;
|
||||||
- clôture du cycle POC `0.1.0` sans ajout fonctionnel de dernière minute ;
|
- clôture du cycle POC `0.1.0` sans ajout fonctionnel de dernière minute ;
|
||||||
- réservation de la direction `0.2.0` : petit kernel moteur, capabilities séparées, adaptateurs plateforme, providers, jeux et services/serveurs composés statiquement ;
|
- réservation de la direction `0.2.0` pour concevoir progressivement le framework modulaire avant toute extension fonctionnelle majeure.
|
||||||
- ajout d'un prompt de conception `0.2.0` afin que l'inventaire détaillé des fonctionnalités et la future architecture soient discutés avant toute implémentation majeure.
|
|
||||||
|
|
||||||
## 0.1.0-3-rc.1 — 2026-09-17
|
## 0.1.0-3-rc.1 — 2026-09-17
|
||||||
|
|
||||||
@@ -20,59 +142,4 @@
|
|||||||
- entrée en phase RC centrée sur la reproductibilité des builds de release, les artefacts de distribution et la documentation de livraison ;
|
- entrée en phase RC centrée sur la reproductibilité des builds de release, les artefacts de distribution et la documentation de livraison ;
|
||||||
- ajout d'une matrice RC et d'une gate workspace complète avant promotion vers `0.1.0`.
|
- ajout d'une matrice RC et d'une gate workspace complète avant promotion vers `0.1.0`.
|
||||||
|
|
||||||
## 0.1.0-2-beta.1 — 2026-09-17
|
Les détails des phases `pre`, `alpha`, `beta` et de leurs correctifs sont conservés dans `deltas/0.1.0/` et `history/0.1.0/`.
|
||||||
|
|
||||||
- validation de `1-alpha.2.fix.5` avec Reflex exécuté dans Tauri/WebView/WASM via Vite + TypeScript ;
|
|
||||||
- validation du tracing unifié Rust/TypeScript et de la sortie `Escape` via le contrat moteur commun ;
|
|
||||||
- passage en phase beta, désormais centrée sur la stabilisation, le packaging et les tests multi-appareils ;
|
|
||||||
- ajout d'une matrice de validation Desktop SDL3, Tauri/WASM, Android AVD x86_64 et Android ARM64 réel ;
|
|
||||||
- ajout d'un audit en lecture seule des frontières statiques nécessaires aux distributions ;
|
|
||||||
- formalisation des gates de packaging Desktop natif, Tauri et Android beta.
|
|
||||||
|
|
||||||
## 0.1.0-1-alpha.1 — 2026-09-16
|
|
||||||
|
|
||||||
- validation de la fin de phase `0-pre.*` avec Snake jouable sur Desktop et Galaxy S9+ ARM64 ;
|
|
||||||
- stabilisation du contrat de sortie `QuitRequest` / `QuitDecision` introduit pendant les derniers correctifs `0-pre.11` ;
|
|
||||||
- introduction de `RuntimeProvenance` dans `engine-v1-platform-api` ;
|
|
||||||
- séparation explicite entre famille de plateforme, classe de device, exécution native/WASM, hôte runtime et profil d'entrée ;
|
|
||||||
- préparation des futurs leaderboards/Hall of Fame pour segmenter les scores selon l'environnement sans imposer de comparaison automatique ;
|
|
||||||
- planification d'un POC Web/WASM dans Tauri comme prochain jalon alpha.
|
|
||||||
|
|
||||||
## 0.1.0-0-pre.4 — 2026-09-16
|
|
||||||
|
|
||||||
- validation de `0-pre.3` enregistrée avec suite workspace complète et smokes Desktop propres ;
|
|
||||||
- déplacement des tests unitaires hors `src/` vers `unit_tests/` et formalisation de `tests/` pour intégration/environnement ;
|
|
||||||
- ajout d’un audit empêchant le retour de corps de tests sous `src/` ;
|
|
||||||
- adoption des tests Cargo ciblés par défaut, la suite workspace complète devenant une gate lourde périodique ;
|
|
||||||
- `cargo fmt --all` devient obligatoire avant la gate lorsqu’un delta modifie du Rust, sans incrément d’en-tête pour les seules modifications rustfmt ;
|
|
||||||
- introduction de `game-logging-lib` avec `tracing`, `tracing-subscriber` et `tracing-appender` ;
|
|
||||||
- initialisation du tracing dans les runners Desktop et helper de tracing local pour les tests.
|
|
||||||
|
|
||||||
## 0.1.0-0-pre.3 — 2026-09-16
|
|
||||||
|
|
||||||
- validation utilisateur de `0.1.0-0-pre.2` avec formatage, audits, check, Clippy strict et tests workspace propres ;
|
|
||||||
- introduction de `EngineFrame`, `InputState`, `EngineGame` et `FixedStepRunner` comme première boucle moteur indépendante de SDL3 ;
|
|
||||||
- branchement des deux POC et des deux runners Desktop sur cette boucle commune ;
|
|
||||||
- introduction d'un état explicite `Available` / `Disabled` / `Unsupported` pour les services plateforme optionnels et d'une déclaration de capacités de monétisation ;
|
|
||||||
- documentation de la monétisation optionnelle par plateforme, y compris distinction Android, Web et Desktop ;
|
|
||||||
- documentation du binaire Desktop SDL3 comme défaut et d'une variante Tauri optionnelle sans duplication du gameplay ;
|
|
||||||
- formalisation de la validation Cargo par l'utilisateur, de l'enregistrement de la validation du delta précédent et du passage automatique au delta suivant ou à un `.fix.N` selon le résultat.
|
|
||||||
|
|
||||||
## 0.1.0-0-pre.2 — 2026-09-15
|
|
||||||
|
|
||||||
- intégration de l'architecture de référence dans une documentation thématique durable ;
|
|
||||||
- ajout des objectifs projet, classification des jeux, abstraction SDL3 multi-plateforme, contrôles, assets, évolution moteur, monétisation et services en ligne ;
|
|
||||||
- ajout d'une crate binaire Desktop par POC, chacune consommant exclusivement la crate lib du jeu correspondant ;
|
|
||||||
- ajout de la politique normative des commandes Cargo, audits, runners, Android, Web et Git ;
|
|
||||||
- mise à jour de l'index documentaire, des contrats de fichiers, de la roadmap et des gates.
|
|
||||||
|
|
||||||
## 0.1.0-0-pre.1 — 2026-09-15
|
|
||||||
|
|
||||||
- création du workspace Cargo multi-crates ;
|
|
||||||
- introduction de la génération `engine-v1` ;
|
|
||||||
- création de deux POC structurels Reflex et Snake ;
|
|
||||||
- séparation stricte des assets hors crates ;
|
|
||||||
- création de la structure Android Java commune et spécifique par jeu ;
|
|
||||||
- adaptation des règles et audits issus de l'expérience KSP ;
|
|
||||||
- adoption du cycle SemVer `pre -> alpha -> beta -> rc -> stable` avec correctifs `.fix.N` ;
|
|
||||||
- adoption des livraisons par archives delta.
|
|
||||||
|
|||||||
24
Cargo.toml
24
Cargo.toml
@@ -1,5 +1,5 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 36
|
# version: 113
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
@@ -9,17 +9,28 @@ members = [
|
|||||||
"crates/engines/engine-v1-sdl",
|
"crates/engines/engine-v1-sdl",
|
||||||
"crates/games/game-reflex-poc",
|
"crates/games/game-reflex-poc",
|
||||||
"crates/games/game-snake-poc",
|
"crates/games/game-snake-poc",
|
||||||
|
"crates/apps/game-realtime-transport-fallback-smoke",
|
||||||
|
"crates/apps/game-realtime-transport-measure",
|
||||||
|
"crates/apps/game-realtime-websocket-smoke",
|
||||||
|
"crates/apps/game-realtime-webtransport-browser-smoke",
|
||||||
|
"crates/apps/game-realtime-webtransport-datagram-smoke",
|
||||||
|
"crates/apps/game-realtime-webtransport-smoke",
|
||||||
"crates/apps/game-reflex-poc-desktop",
|
"crates/apps/game-reflex-poc-desktop",
|
||||||
"crates/apps/game-snake-poc-desktop",
|
"crates/apps/game-snake-poc-desktop",
|
||||||
"crates/common/game-assets-lib",
|
"crates/common/game-assets-lib",
|
||||||
"crates/common/game-logging-lib",
|
"crates/common/game-logging-lib",
|
||||||
|
"crates/common/game-realtime-transport-lib",
|
||||||
|
"crates/common/game-realtime-websocket-lib",
|
||||||
|
"crates/common/game-realtime-webtransport-lib",
|
||||||
"crates/apps/game-android-entrypoint",
|
"crates/apps/game-android-entrypoint",
|
||||||
"crates/apps/game-reflex-poc-tauri",
|
"crates/apps/game-reflex-poc-tauri",
|
||||||
"crates/apps/game-reflex-poc-wasm",
|
"crates/apps/game-reflex-poc-wasm",
|
||||||
|
"crates/apps/game-snake-poc-wasm",
|
||||||
|
"crates/apps/game-snake-poc-tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.3.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/games"
|
repository = "https://git.sasedev.com/Sasedev/games"
|
||||||
@@ -27,6 +38,9 @@ authors = ["Sasedev <games@sasedev.com>"]
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
futures-util = { version = "0.3.34", default-features = false }
|
||||||
|
gloo-timers = "0.4.0"
|
||||||
|
rcgen = { version = "0.14.10", default-features = false }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
sdl3 = "^0.20"
|
sdl3 = "^0.20"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
@@ -36,7 +50,13 @@ tracing-subscriber = "0.3.23"
|
|||||||
tauri = "2"
|
tauri = "2"
|
||||||
tauri-build = "2"
|
tauri-build = "2"
|
||||||
tauri-plugin-tracing = "^0.3"
|
tauri-plugin-tracing = "^0.3"
|
||||||
|
tokio = "1.53.1"
|
||||||
|
tokio-tungstenite = { version = "0.30.0", default-features = false }
|
||||||
|
url = "2.5.8"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
web-transport-quinn = { version = "0.12.1", default-features = false }
|
||||||
|
web-transport-wasm = "0.6.0"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
missing_docs = "warn"
|
missing_docs = "warn"
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 6 -->
|
<!-- version: 83 -->
|
||||||
|
|
||||||
# games.sasedev
|
# games.sasedev
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
|
|||||||
- assets hors des crates sous `assets/` ;
|
- assets hors des crates sous `assets/` ;
|
||||||
- `assets/common/` pour les ressources mutualisées et un répertoire par jeu pour les ressources spécifiques ;
|
- `assets/common/` pour les ressources mutualisées et un répertoire par jeu pour les ressources spécifiques ;
|
||||||
- frontend Android sous `Android/`, en Java, avec une partie commune et une partie spécifique par jeu ;
|
- frontend Android sous `Android/`, en Java, avec une partie commune et une partie spécifique par jeu ;
|
||||||
|
- Android de production orienté SDL3 natif/Java/JNI ; Tauri Android reste un POC de référence et non un template de jeu ;
|
||||||
|
- hosts navigateur directs sous `Web/`, avec frontend Vite/TypeScript séparé des adapters Rust/WASM ;
|
||||||
- monétisation optionnelle et spécifique à chaque plateforme/distribution ;
|
- monétisation optionnelle et spécifique à chaque plateforme/distribution ;
|
||||||
- runner Desktop natif SDL3 par défaut, avec variante Tauri uniquement si un besoin futur la justifie ;
|
- runner Desktop natif SDL3 par défaut, avec variante Tauri uniquement si un besoin futur la justifie ;
|
||||||
- documentation sous `docs/` ;
|
- documentation sous `docs/` ;
|
||||||
@@ -23,7 +25,11 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
|
|||||||
|
|
||||||
## Baseline
|
## Baseline
|
||||||
|
|
||||||
Version courante : `0.1.0-1-alpha.1`.
|
Version stable de référence : `0.3.5`.
|
||||||
|
|
||||||
|
Version active : `0.3.5`. Prochaine version planifiée : `0.3.6-alpha.1`. `0.3.2` reste différée.
|
||||||
|
|
||||||
|
La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. La stable `0.3.5` ajoute le second backend WebTransport/QUIC retenu : chemins natif et navigateur, composition WebTransport-first avec fallback WebSocket strictement classifié, datagrams backend-spécifiques, caractérisation loopback bornée et cross-compilation Android ARM64/API 21. WebSocket reste la baseline/fallback de référence ; WebTransport est conservé comme second backend realtime. Cette décision ne prétend pas qu’un résultat loopback prédit les performances Internet/mobile et ne transforme pas les datagrams en capacité du contrat fiable commun.
|
||||||
|
|
||||||
Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme.
|
Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme.
|
||||||
|
|
||||||
@@ -39,4 +45,4 @@ Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-sna
|
|||||||
|
|
||||||
## Diagnostics et tests
|
## Diagnostics et tests
|
||||||
|
|
||||||
Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`.
|
Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite, et `game-realtime-webtransport-lib`, second backend WebTransport/QUIC retenu dont le chemin natif couvre TLS/pinning, établissement de session et stream fiable principal, tandis que son chemin client WASM compile contre l'API WebTransport du navigateur avec le même framing fiable et le même contrat commun. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` et `game-realtime-webtransport-smoke` fournissent les preuves runtime localhost hors harness des deux backends fiables natifs ; `game-realtime-webtransport-browser-smoke` et son host sous `Web/` portent la preuve navigateur WebTransport réelle sans couplage gameplay ; `game-realtime-transport-fallback-smoke` prouve séparément la composition WebTransport-first et le fallback WebSocket classifié sans introduire de manager générique ; `game-realtime-webtransport-datagram-smoke` exerce enfin la capacité datagram WebTransport native sans l'ajouter au contrat fiable commun. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`.
|
||||||
|
|||||||
65
ROADMAP.md
65
ROADMAP.md
@@ -1,27 +1,56 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 14 -->
|
<!-- version: 28 -->
|
||||||
|
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
|
## Légende
|
||||||
|
|
||||||
|
- `( )` — planned ;
|
||||||
|
- `(x)` — completed ;
|
||||||
|
- `(d)` — deferred ;
|
||||||
|
- `(c)` — cancelled.
|
||||||
|
|
||||||
|
Les marqueurs qualifient une ligne de scope. Une même version peut donc apparaître plusieurs fois si une partie est livrée, une partie reportée et une autre annulée.
|
||||||
|
|
||||||
## 0.1.0 — Fondation POC
|
## 0.1.0 — Fondation POC
|
||||||
|
|
||||||
- [x] `0-pre.1` — squelette du workspace, règles, architecture, versions, assets, Java Android commun/spécifique et deux crates POC.
|
- (x) `0.1.0` — squelette du workspace, règles, architecture, versions et assets.
|
||||||
- [x] `0-pre.2` — consolidation documentaire issue de l'architecture de référence, runners Desktop par jeu et politique normative des commandes.
|
- (x) `0.1.0` — moteur V1 POC, boucle de jeu, input abstrait et rendu SDL3 minimal.
|
||||||
- [x] `0-pre.3` — première boucle moteur exécutable Desktop : temps, input abstrait, capacités de monétisation optionnelles et état de jeu minimal via les runners.
|
- (x) `0.1.0` — Reflex et Snake jouables comme POC de réutilisation.
|
||||||
- [x] `0-pre.4` — architecture de tests hors `src/`, stratégie de tests ciblés et socle `tracing`/`tracing-subscriber`/`tracing-appender`.
|
- (x) `0.1.0` — Desktop SDL3 natif, Android SDL3/Java/JNI et Tauri/WebView/WASM Reflex.
|
||||||
- [x] `0-pre.5` — intégration SDL3 Desktop réelle et premier rendu POC Reflex.
|
- (x) `0.1.0` — tracing commun, provenance runtime, packaging et validation multi-appareils.
|
||||||
- [x] `0-pre.6` — fondation Gradle Android, SDL3 AAR, Activity Java commune et politique de fenêtre Desktop redimensionnable.
|
|
||||||
- [x] `0-pre.7` — compilation Rust Android `cdylib`, point d'entrée SDL Android et premier lancement APK sur appareil/émulateur.
|
Le détail historique des prereleases `0.1.0-*` reste dans `deltas/0.1.0/` et `history/0.1.0/`.
|
||||||
- [x] `0-pre.8` — bridge Java/JNI minimal et input tactile Android.
|
|
||||||
- [x] `0-pre.9` — assets communs + spécifiques empaquetés sans copie dans les crates.
|
|
||||||
- [x] `0-pre.10` — POC Reflex jouable Desktop + Android.
|
|
||||||
- [x] `0-pre.11` — POC Snake jouable et validation de la réutilisation du moteur.
|
|
||||||
- [x] `1-alpha.1` — première API moteur V1 volontairement stabilisée et provenance runtime/input pour les futures sessions et scores.
|
|
||||||
- [x] `1-alpha.2` — POC Web/WASM embarqué dans Tauri, sans site distant, et validation de la frontière des services Web/ads.
|
|
||||||
- [x] `2-beta.1` — stabilisation, packaging, tests multi-appareils.
|
|
||||||
- [x] `3-rc.1` — candidat de release du socle 0.1.0.
|
|
||||||
- [x] `0.1.0` — première baseline stable du framework POC.
|
|
||||||
|
|
||||||
## 0.2.0 — Conception du framework modulaire
|
## 0.2.0 — Conception du framework modulaire
|
||||||
|
|
||||||
- [ ] `0.2.0` — inventorier et réserver les capabilities utiles, définir les couches et dépendances autorisées, établir la matrice plateformes et les archétypes de jeux, définir la composition statique et l'architecture cible des crates, puis seulement planifier les implémentations progressives.
|
- (x) `0.2.0` — fixer la gouvernance documentaire, la nomenclature et le cycle de maturation des idées et décisions.
|
||||||
|
- (x) `0.2.0` — inventorier et réserver les capabilities déjà justifiées sans les implémenter prématurément.
|
||||||
|
- (x) `0.2.0` — définir les couches, les dépendances autorisées et la frontière entre kernel, capability, plateforme, provider, système de jeu et service.
|
||||||
|
- (x) `0.2.0` — établir la matrice plateformes et les archétypes de jeux utilisés pour challenger l'architecture.
|
||||||
|
- (x) `0.2.0` — retenir la composition Rust statique comme direction de référence.
|
||||||
|
- (d) `0.2.0` — figer un manifest produit/jeu et l'architecture physique définitive des crates → target TBD après observation des POC `0.3.x`.
|
||||||
|
- (x) `0.2.0` — définir la trajectoire d'implémentation progressive `0.3.x` POC puis `0.4.x` Uroburas.
|
||||||
|
- (x) `0.2.0` — consolider les études acceptées en architecture/règles durables, notamment ownership, réseau, Uroburas et POC, puis fermer les points encore candidats.
|
||||||
|
- (x) `0.2.0` — formaliser la stratégie réseau : Actix Web pour le Web/API, asset delivery auto-hébergé H2/H3, WebSocket baseline et WebTransport/QUIC candidat.
|
||||||
|
- (x) `0.2.0` — préparer une trajectoire `0.3.x` dédiée aux POC plateforme/réseau avec Snake comme jeu-sonde.
|
||||||
|
- (x) `0.2.0` — préparer la trajectoire `0.4.x` Uroburas Mode 1 et le prompt de session associé après gel de la conception.
|
||||||
|
|
||||||
|
## 0.3.0 — Baseline Snake + premier POC Web direct
|
||||||
|
|
||||||
|
- (x) `0.3.0` — nettoyer la baseline Snake sans introduire de logique Uroburas et maintenir le runner Desktop SDL3 fonctionnel.
|
||||||
|
- (x) `0.3.0` — produire une adaptation WASM dédiée à Snake sans déplacer le gameplay hors de `game-snake-poc`.
|
||||||
|
- (x) `0.3.0` — réaliser un POC navigateur direct Snake couvrant clavier, boutons directionnels tactiles, resize, lifecycle, logging, assets et provenance runtime.
|
||||||
|
- (x) `0.3.0` — utiliser uniquement Cargo, `wasm-bindgen` et Vite/npm pour le chemin de build Web ; aucun script Python ne pilote ce build.
|
||||||
|
- (x) `0.3.0` — documenter les duplications observées et conserver les adapters Web/WASM spécifiques tant qu’un second consommateur ne justifie pas une extraction commune.
|
||||||
|
|
||||||
|
## Série 0.3.x — trajectoire actuelle après 0.3.1
|
||||||
|
|
||||||
|
- (x) `0.3.1` — Snake Tauri Android livré comme POC de référence réutilisant la baseline Web/WASM ; la production Android reste orientée SDL3 natif.
|
||||||
|
- (d) `0.3.2` — Tauri Desktop + Snake est différé tant qu’une solution de monétisation desktop/WebView, notamment vidéo récompensée, n’est pas démontrée techniquement et contractuellement ; ne pas créer cette distribution seulement pour réutiliser le host Web.
|
||||||
|
- (x) `0.3.3` — Android SDL3 natif multi-ABI livré avec Cargo/Gradle natifs, APK universal pour tests, AAB comme cible de distribution, `minSdk 21` fumé et compatibilité 16 KB 64 bits validée, sans script Python de build.
|
||||||
|
- (x) `0.3.4` — API de transport realtime + WebSocket/tokio-tungstenite baseline livrée, sans serveur Uroburas Mode 3.
|
||||||
|
- (x) `0.3.5` — POC WebTransport/QUIC sur le même protocole, avec comparaison mesurée et fallback WebSocket.
|
||||||
|
- ( ) `0.3.6` — consolidation des POC plateforme/réseau et préparation de la baseline `0.4.x`.
|
||||||
|
|
||||||
|
La numérotation `0.3.1+` reste révisable à partir des résultats réels ; les lignes ci-dessus décrivent le planning actuel, pas une obligation de créer des versions artificielles.
|
||||||
|
|||||||
8
RULES.md
8
RULES.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: RULES.md -->
|
<!-- file: RULES.md -->
|
||||||
<!-- version: 3 -->
|
<!-- version: 6 -->
|
||||||
|
|
||||||
# Index normatif games.sasedev
|
# Index normatif games.sasedev
|
||||||
|
|
||||||
@@ -15,7 +15,11 @@ Les règles détaillées sont maintenues sous `docs/rules/` et sont cumulatives
|
|||||||
4. [`docs/rules/RULES_DOCUMENTATION.md`](docs/rules/RULES_DOCUMENTATION.md) — règles Markdown et cycle documentaire ;
|
4. [`docs/rules/RULES_DOCUMENTATION.md`](docs/rules/RULES_DOCUMENTATION.md) — règles Markdown et cycle documentaire ;
|
||||||
5. [`docs/rules/FILE_CONTRACTS.md`](docs/rules/FILE_CONTRACTS.md) — responsabilités des principales familles de fichiers ;
|
5. [`docs/rules/FILE_CONTRACTS.md`](docs/rules/FILE_CONTRACTS.md) — responsabilités des principales familles de fichiers ;
|
||||||
6. [`docs/rules/VERSION_WORKFLOW.md`](docs/rules/VERSION_WORKFLOW.md) — SemVer, niveaux de maturité, fixes, deltas et livraisons ;
|
6. [`docs/rules/VERSION_WORKFLOW.md`](docs/rules/VERSION_WORKFLOW.md) — SemVer, niveaux de maturité, fixes, deltas et livraisons ;
|
||||||
7. [`docs/rules/RULES_COMMANDS.md`](docs/rules/RULES_COMMANDS.md) — politique d’exécution des commandes Cargo, audits, runners, Android, Web et Git.
|
7. [`docs/rules/RULES_COMMANDS.md`](docs/rules/RULES_COMMANDS.md) — politique d’exécution des commandes Cargo, audits, runners, Android, Web et Git ;
|
||||||
|
8. [`docs/rules/RULES_VALIDATION_MATRIX.md`](docs/rules/RULES_VALIDATION_MATRIX.md) — matrice évolutive des commandes, dépendances de validation et politiques de nettoyage ;
|
||||||
|
9. [`docs/rules/RULES_SESSION_PLANNING.md`](docs/rules/RULES_SESSION_PLANNING.md) — cadrage `alpha.1`, dimensionnement des sessions et cycle de transmission ;
|
||||||
|
10. [`docs/rules/PROMPT_STRUCTURE.md`](docs/rules/PROMPT_STRUCTURE.md) — structure minimale des prompts de reprise et rappels de workflow obligatoires ;
|
||||||
|
11. [`docs/rules/RULES_SERVER_HOSTING.md`](docs/rules/RULES_SERVER_HOSTING.md) — contraintes durables de portabilité et préférence d'auto-hébergement.
|
||||||
|
|
||||||
## Hiérarchie
|
## Hiérarchie
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>games.sasedev WebTransport browser smoke</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>WebTransport browser smoke</h1>
|
||||||
|
<p id="environment-status">Checking browser environment…</p>
|
||||||
|
<label>
|
||||||
|
WebTransport endpoint
|
||||||
|
<input id="endpoint" type="text" size="72" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Certificate SHA-256
|
||||||
|
<input id="certificate-hash" type="text" size="72" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<button id="run" type="button">Run smoke</button>
|
||||||
|
<pre id="result">IDLE</pre>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./ts/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// file: Web/game-realtime-webtransport-browser-smoke/frontend/ts/main.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import init, { run_browser_smoke } from "@webtransport-browser-smoke-wasm";
|
||||||
|
|
||||||
|
function requireElement<T extends Element>(selector: string): T {
|
||||||
|
const element = document.querySelector<T>(selector);
|
||||||
|
if (element === null) {
|
||||||
|
throw new Error(`Required browser smoke element is missing: ${selector}`);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFromQuery(endpoint: HTMLInputElement, certificateHash: HTMLInputElement): void {
|
||||||
|
const parameters = new URLSearchParams(window.location.search);
|
||||||
|
const endpointValue = parameters.get("endpoint");
|
||||||
|
const hashValue = parameters.get("sha256");
|
||||||
|
if (endpointValue !== null) {
|
||||||
|
endpoint.value = endpointValue;
|
||||||
|
}
|
||||||
|
if (hashValue !== null) {
|
||||||
|
certificateHash.value = hashValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const environmentStatus = requireElement<HTMLParagraphElement>("#environment-status");
|
||||||
|
const endpoint = requireElement<HTMLInputElement>("#endpoint");
|
||||||
|
const certificateHash = requireElement<HTMLInputElement>("#certificate-hash");
|
||||||
|
const run = requireElement<HTMLButtonElement>("#run");
|
||||||
|
const result = requireElement<HTMLPreElement>("#result");
|
||||||
|
populateFromQuery(endpoint, certificateHash);
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
environmentStatus.textContent = "FAIL: page is not running in a secure context";
|
||||||
|
run.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!("WebTransport" in window)) {
|
||||||
|
environmentStatus.textContent = "FAIL: this browser does not expose WebTransport";
|
||||||
|
run.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await init();
|
||||||
|
environmentStatus.textContent = "Secure context and WebTransport available; WASM loaded";
|
||||||
|
run.addEventListener("click", () => {
|
||||||
|
run.disabled = true;
|
||||||
|
result.textContent = "RUNNING";
|
||||||
|
void run_browser_smoke(endpoint.value.trim(), certificateHash.value.trim())
|
||||||
|
.then(message => {
|
||||||
|
result.textContent = message;
|
||||||
|
})
|
||||||
|
.catch(caughtError => {
|
||||||
|
result.textContent = `game-realtime-webtransport-browser-smoke: FAIL: ${String(caughtError)}`;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
run.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (endpoint.value.length > 0 && certificateHash.value.length > 0) {
|
||||||
|
run.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch(caughtError => {
|
||||||
|
const result = document.querySelector<HTMLPreElement>("#result");
|
||||||
|
if (result !== null) {
|
||||||
|
result.textContent = `game-realtime-webtransport-browser-smoke: FAIL: ${String(caughtError)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
17
Web/game-realtime-webtransport-browser-smoke/package.json
Normal file
17
Web/game-realtime-webtransport-browser-smoke/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "game-realtime-webtransport-browser-smoke-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.3.5",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"wasm:dev": "cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown && wasm-bindgen ../../../builds/sasedev-games/target/wasm32-unknown-unknown/debug/game_realtime_webtransport_browser_smoke.wasm --target web --out-dir ../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm --out-name game_realtime_webtransport_browser_smoke",
|
||||||
|
"wasm:build": "cargo build -p game-realtime-webtransport-browser-smoke --lib --release --target wasm32-unknown-unknown && wasm-bindgen ../../../builds/sasedev-games/target/wasm32-unknown-unknown/release/game_realtime_webtransport_browser_smoke.wasm --target web --out-dir ../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm --out-name game_realtime_webtransport_browser_smoke",
|
||||||
|
"dev": "npm run wasm:dev && vite",
|
||||||
|
"build": "npm run wasm:build && tsc && vite build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1",
|
||||||
|
"typescript": "^7.0",
|
||||||
|
"vite": "^8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Web/game-realtime-webtransport-browser-smoke/tsconfig.json
Normal file
35
Web/game-realtime-webtransport-browser-smoke/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@webtransport-browser-smoke-wasm": [
|
||||||
|
"../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm/game_realtime_webtransport_browser_smoke.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"frontend",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
50
Web/game-realtime-webtransport-browser-smoke/vite.config.ts
Normal file
50
Web/game-realtime-webtransport-browser-smoke/vite.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// file: Web/game-realtime-webtransport-browser-smoke/vite.config.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { defineConfig, normalizePath } from "vite";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const repositoryRoot = normalizePath(resolve(appRoot, "../.."));
|
||||||
|
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||||
|
const externalBuildRoot = normalizePath(resolve(repositoryRoot, "../builds/sasedev-games/game-realtime-webtransport-browser-smoke"));
|
||||||
|
const wasmRoot = normalizePath(resolve(externalBuildRoot, "wasm"));
|
||||||
|
const wasmModule = normalizePath(resolve(wasmRoot, "game_realtime_webtransport_browser_smoke.js"));
|
||||||
|
const frontendDist = normalizePath(resolve(externalBuildRoot, "dist"));
|
||||||
|
const viteCacheDir = normalizePath(resolve(externalBuildRoot, "vite-cache"));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "./",
|
||||||
|
cacheDir: viteCacheDir,
|
||||||
|
clearScreen: false,
|
||||||
|
root: frontendRoot,
|
||||||
|
publicDir: false,
|
||||||
|
input: {
|
||||||
|
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@webtransport-browser-smoke-wasm": wasmModule,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: frontendDist,
|
||||||
|
emptyOutDir: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 1435,
|
||||||
|
strictPort: true,
|
||||||
|
fs: {
|
||||||
|
allow: [appRoot, repositoryRoot, externalBuildRoot],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4175,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
118
Web/game-snake-poc/frontend/main.html
Normal file
118
Web/game-snake-poc/frontend/main.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<!-- file: Web/game-snake-poc/frontend/main.html -->
|
||||||
|
<!-- version: 4 -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport">
|
||||||
|
<meta content="#593196" name="theme-color">
|
||||||
|
<link rel="stylesheet" href="sass/main.scss">
|
||||||
|
<title>Snake POC — Web direct</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav class="navbar h-100 py-0 bg-light text-dark">
|
||||||
|
<div class="container-fluid px-3 px-md-4 flex-nowrap">
|
||||||
|
<div class="navbar-brand d-flex align-items-center me-3 flex-nowrap min-w-0">
|
||||||
|
<span class="app-logo d-inline-flex align-items-center justify-content-center text-primary" aria-hidden="true">
|
||||||
|
<i class="fa-solid fa-gamepad"></i>
|
||||||
|
</span>
|
||||||
|
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">Snake POC</span>
|
||||||
|
<span class="mx-2 fs-5 text-body-secondary d-none d-sm-inline" aria-hidden="true">—</span>
|
||||||
|
<span class="fs-5 text-body text-nowrap d-none d-sm-inline">Web direct</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
<span class="badge text-bg-light border text-dark d-none d-md-inline">Rust/WASM</span>
|
||||||
|
<span id="runtime-status" class="badge text-bg-secondary" role="status" aria-live="polite">Initialisation…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main container-fluid p-0">
|
||||||
|
<div class="row g-0 h-100 flex-nowrap">
|
||||||
|
<section class="col app-content app-scrollable h-100" data-simplebar>
|
||||||
|
<div class="container-fluid px-3 px-md-4 py-3 py-md-4">
|
||||||
|
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||||
|
<div class="card-body p-3 p-md-4">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Snake</h1>
|
||||||
|
<p class="text-body-secondary mb-0">Gameplay Rust portable, adapter WebAssembly et rendu Canvas piloté par TypeScript.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap" aria-label="État de la partie">
|
||||||
|
<output id="score" class="badge text-bg-primary">Score : 0</output>
|
||||||
|
<output id="length" class="badge text-bg-info">Longueur : 0</output>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 g-lg-4 align-items-start">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card border-primary-subtle shadow-sm">
|
||||||
|
<div class="card-body p-2 p-sm-3">
|
||||||
|
<div class="game-stage">
|
||||||
|
<canvas id="game" width="640" height="640" tabindex="0" aria-label="Zone de jeu Snake"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="card shadow-sm app-controls-card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<span class="fw-semibold"><i class="fa-solid fa-keyboard me-2" aria-hidden="true"></i>Contrôles</span>
|
||||||
|
<span class="small text-body-secondary">clavier / tactile</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-body-secondary">Utilisez les flèches, WASD, ZQSD ou les boutons directionnels.</p>
|
||||||
|
<div class="direction-pad mx-auto" aria-label="Contrôles directionnels tactiles">
|
||||||
|
<button class="btn btn-outline-primary direction-up" type="button" data-direction="up" aria-label="Haut">
|
||||||
|
<i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary direction-left" type="button" data-direction="left" aria-label="Gauche">
|
||||||
|
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary direction-down" type="button" data-direction="down" aria-label="Bas">
|
||||||
|
<i class="fa-solid fa-arrow-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary direction-right" type="button" data-direction="right" aria-label="Droite">
|
||||||
|
<i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<dl class="runtime-details small border-top pt-3 mt-3 mb-0">
|
||||||
|
<div class="d-flex justify-content-between gap-3">
|
||||||
|
<dt class="text-body-secondary fw-normal">Provenance</dt>
|
||||||
|
<dd class="mb-2 text-end"><output id="runtime-provenance">web / browser / wasm / unknown / unknown</output></dd>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between gap-3">
|
||||||
|
<dt class="text-body-secondary fw-normal">Assets</dt>
|
||||||
|
<dd class="mb-0 text-end"><output id="asset-status">chargement…</output></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="alert alert-info small mt-3 mb-0" role="note">
|
||||||
|
Les entrées ne font pas avancer la simulation : elles sont consommées au prochain <code>tick()</code> WASM.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="startup-error" class="alert alert-danger mt-3 mb-0" role="alert" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer bg-dark text-light">
|
||||||
|
<div class="container h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<small>© 2026 SASEDEV</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="module" src="ts/main.ts" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
165
Web/game-snake-poc/frontend/sass/_app.scss
Normal file
165
Web/game-snake-poc/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/sass/_app.scss
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
$app-header-height: 72px;
|
||||||
|
$app-footer-height: 42px;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $gray-100;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-0 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: $app-header-height;
|
||||||
|
z-index: 1020;
|
||||||
|
border-bottom: 2px solid rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: $app-footer-height;
|
||||||
|
z-index: 1020;
|
||||||
|
border-top: 2px solid rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - $app-header-height - $app-footer-height);
|
||||||
|
margin-top: $app-header-height;
|
||||||
|
margin-bottom: $app-footer-height;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main > .row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-scrollable {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-stage {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
margin-inline: auto;
|
||||||
|
aspect-ratio: 3 / 5;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $black;
|
||||||
|
border: 1px solid rgba($primary, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#game {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 3px rgba($primary, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-controls-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-details dd {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(64px, 88px));
|
||||||
|
grid-template-rows: repeat(2, minmax(56px, 72px));
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad .btn {
|
||||||
|
touch-action: manipulation;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-up {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-left {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-down {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-right {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
$mobile-header-height: 64px;
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
height: $mobile-header-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
height: calc(100vh - $mobile-header-height - $app-footer-height);
|
||||||
|
margin-top: $mobile-header-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: repeat(3, minmax(72px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
160
Web/game-snake-poc/frontend/sass/_bootswatch.scss
Normal file
160
Web/game-snake-poc/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/sass/_bootswatch.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
// Pulse 5.3.8
|
||||||
|
// Bootswatch
|
||||||
|
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&:active:focus,
|
||||||
|
&.active:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-secondary {
|
||||||
|
color: $gray-900;
|
||||||
|
background-color: $white;
|
||||||
|
border-color: #ccc;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gray-900;
|
||||||
|
background-color: $gray-300;
|
||||||
|
border-color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: tint-color($gray-900, 5%);
|
||||||
|
background-color: $white;
|
||||||
|
border-color: tint-color(#ccc, 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-warning {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-primary:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($primary, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-secondary:focus {
|
||||||
|
box-shadow: 0 0 5px $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-success:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($success, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-info:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($info, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-warning:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($warning, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-danger:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($danger, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
.table .thead-dark th {
|
||||||
|
background-color: $secondary;
|
||||||
|
border-color: $table-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-primary,
|
||||||
|
.table-secondary,
|
||||||
|
.table-success,
|
||||||
|
.table-warning,
|
||||||
|
.table-danger,
|
||||||
|
.table-info,
|
||||||
|
.table-light {
|
||||||
|
--#{$prefix}table-color: #{$body-color};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navs
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.nav-link.active {
|
||||||
|
border-width: 0 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active,
|
||||||
|
.nav-link.active:hover,
|
||||||
|
.nav-link.active:focus {
|
||||||
|
border-bottom: 1px solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item+.nav-item {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
&-item.active {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicators
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
&.bg-light {
|
||||||
|
color: $dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
&-item {
|
||||||
|
color: rgba(255, 255, 255, .8);
|
||||||
|
|
||||||
|
&.active,
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $list-group-hover-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled:hover {
|
||||||
|
color: $list-group-disabled-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Web/game-snake-poc/frontend/sass/_fontawesome.scss
Normal file
19
Web/game-snake-poc/frontend/sass/_fontawesome.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/sass/_fontawesome.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
|
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||||
|
// $font-path: '../webfonts',
|
||||||
|
//);
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
|
// use fonts from @fortawesome/fontawesome-free
|
||||||
|
$font-path: '@fortawesome/fontawesome-free/webfonts',
|
||||||
|
);
|
||||||
|
// load Font Awesome core
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||||
|
|
||||||
|
// load and make available Font Awesome helpers (mixins, functions, and variables)
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||||
248
Web/game-snake-poc/frontend/sass/_simplebar.scss
Normal file
248
Web/game-snake-poc/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/sass/_simplebar.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/* Rtl support */
|
||||||
|
[data-simplebar] {
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
max-width: inherit;
|
||||||
|
max-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-mask {
|
||||||
|
direction: inherit;
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
// z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-offset {
|
||||||
|
direction: inherit !important;
|
||||||
|
box-sizing: inherit !important;
|
||||||
|
resize: none !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-content-wrapper {
|
||||||
|
direction: inherit;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-hide-scrollbar {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
&:before {
|
||||||
|
content: ' ';
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-placeholder {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-height-auto-observer-wrapper {
|
||||||
|
box-sizing: inherit !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1px;
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
max-height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
// z-index: -1;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
flex-grow: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-height-auto-observer {
|
||||||
|
box-sizing: inherit;
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1000%;
|
||||||
|
width: 1000%;
|
||||||
|
min-height: 1px;
|
||||||
|
min-width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
// z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track {
|
||||||
|
// z-index: 1;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-simplebar].simplebar-dragging {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-height: 10px;
|
||||||
|
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
background: black;
|
||||||
|
border-radius: 7px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s 0.5s linear;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar.simplebar-visible {
|
||||||
|
&:before {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition-delay: 0s;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 0;
|
||||||
|
width: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-horizontal {
|
||||||
|
left: 0;
|
||||||
|
height: 11px;
|
||||||
|
|
||||||
|
.simplebar-scrollbar {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 10px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-simplebar-direction='rtl'] {
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-dummy-scrollbar-size {
|
||||||
|
direction: rtl;
|
||||||
|
position: fixed;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
height: 500px;
|
||||||
|
width: 500px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: scroll;
|
||||||
|
-ms-overflow-style: scrollbar !important;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
95
Web/game-snake-poc/frontend/sass/_variables.scss
Normal file
95
Web/game-snake-poc/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/sass/_variables.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
// Pulse 5.3.8
|
||||||
|
// Bootswatch
|
||||||
|
|
||||||
|
$theme: "pulse" !default;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Color system
|
||||||
|
//
|
||||||
|
|
||||||
|
$white: #fff !default;
|
||||||
|
$gray-100: #fafafa !default;
|
||||||
|
$gray-200: #f9f8fc !default;
|
||||||
|
$gray-300: #ededed !default;
|
||||||
|
$gray-400: #cbc8d0 !default;
|
||||||
|
$gray-500: #adb5bd !default;
|
||||||
|
$gray-600: #868e96 !default;
|
||||||
|
$gray-700: #444 !default;
|
||||||
|
$gray-800: #343a40 !default;
|
||||||
|
$gray-900: #17141f !default;
|
||||||
|
$black: #000 !default;
|
||||||
|
|
||||||
|
$blue: #007bff !default;
|
||||||
|
$indigo: #6610f2 !default;
|
||||||
|
$purple: #593196 !default;
|
||||||
|
$pink: #e83e8c !default;
|
||||||
|
$red: #fc3939 !default;
|
||||||
|
$orange: #fd7e14 !default;
|
||||||
|
$yellow: #efa31d !default;
|
||||||
|
$green: #13b955 !default;
|
||||||
|
$teal: #20c997 !default;
|
||||||
|
$cyan: #009cdc !default;
|
||||||
|
|
||||||
|
$primary: $purple !default;
|
||||||
|
$secondary: #a991d4 !default;
|
||||||
|
$success: $green !default;
|
||||||
|
$info: $cyan !default;
|
||||||
|
$warning: $yellow !default;
|
||||||
|
$danger: $red !default;
|
||||||
|
$light: $gray-200 !default;
|
||||||
|
$dark: $gray-900 !default;
|
||||||
|
|
||||||
|
$min-contrast-ratio: 2.1 !default;
|
||||||
|
|
||||||
|
// Options
|
||||||
|
|
||||||
|
$enable-rounded: false !default;
|
||||||
|
|
||||||
|
// Body
|
||||||
|
|
||||||
|
$body-color: $gray-700 !default;
|
||||||
|
|
||||||
|
// Links
|
||||||
|
|
||||||
|
$link-hover-color: $primary !default;
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
$table-color: initial !default;
|
||||||
|
|
||||||
|
$table-border-color: rgba(0, 0, 0, .05) !default;
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
|
||||||
|
$input-focus-border-color: $primary !default;
|
||||||
|
|
||||||
|
// Dropdowns
|
||||||
|
|
||||||
|
$dropdown-link-hover-color: $white !default;
|
||||||
|
$dropdown-link-hover-bg: $primary !default;
|
||||||
|
|
||||||
|
// Navs
|
||||||
|
|
||||||
|
$nav-tabs-border-color: $gray-300 !default;
|
||||||
|
$nav-tabs-link-hover-border-color: $primary !default;
|
||||||
|
|
||||||
|
// Navbar
|
||||||
|
|
||||||
|
$navbar-padding-y: 1.2rem !default;
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
|
||||||
|
$progress-bg: $gray-300 !default;
|
||||||
|
$progress-bar-bg: $primary !default;
|
||||||
|
|
||||||
|
// List group
|
||||||
|
|
||||||
|
$list-group-bg: $gray-900 !default;
|
||||||
|
$list-group-border-color: transparent !default;
|
||||||
|
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||||
|
$list-group-active-color: $white !default;
|
||||||
|
$list-group-active-bg: $list-group-bg !default;
|
||||||
|
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||||
10
Web/game-snake-poc/frontend/sass/main.scss
Normal file
10
Web/game-snake-poc/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/sass/main.scss
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
@import "bootstrap/scss/functions";
|
||||||
|
@import "variables";
|
||||||
|
@import "fontawesome";
|
||||||
|
@import "simplebar";
|
||||||
|
@import "bootstrap/scss/bootstrap";
|
||||||
|
@import "bootswatch";
|
||||||
|
@import "app";
|
||||||
54
Web/game-snake-poc/frontend/ts/assets.ts
Normal file
54
Web/game-snake-poc/frontend/ts/assets.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/assets.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
export interface SnakeWebAssets {
|
||||||
|
engineGeneration: number;
|
||||||
|
game: string;
|
||||||
|
gameKind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommonRuntimeAsset {
|
||||||
|
schema: number;
|
||||||
|
scope: string;
|
||||||
|
engine_generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameRuntimeAsset {
|
||||||
|
schema: number;
|
||||||
|
game: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(relativePath: string): Promise<T> {
|
||||||
|
const response = await fetch(new URL(relativePath, document.baseURI));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Asset runtime indisponible (${response.status}) : ${relativePath}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommonRuntime(asset: CommonRuntimeAsset): void {
|
||||||
|
if (asset.schema !== 1 || asset.scope !== "common" || asset.engine_generation !== 1) {
|
||||||
|
throw new Error("Asset common://data/runtime.json invalide pour le POC Web Snake.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGameRuntime(asset: GameRuntimeAsset): void {
|
||||||
|
if (asset.schema !== 1 || asset.game !== "game-snake-poc" || asset.kind !== "snake") {
|
||||||
|
throw new Error("Asset game://data/game.json invalide pour le POC Web Snake.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSnakeWebAssets(): Promise<SnakeWebAssets> {
|
||||||
|
const [commonRuntime, gameRuntime] = await Promise.all([
|
||||||
|
fetchJson<CommonRuntimeAsset>("./common/data/runtime.json"),
|
||||||
|
fetchJson<GameRuntimeAsset>("./game/data/game.json"),
|
||||||
|
]);
|
||||||
|
validateCommonRuntime(commonRuntime);
|
||||||
|
validateGameRuntime(gameRuntime);
|
||||||
|
return {
|
||||||
|
engineGeneration: commonRuntime.engine_generation,
|
||||||
|
game: gameRuntime.game,
|
||||||
|
gameKind: gameRuntime.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
179
Web/game-snake-poc/frontend/ts/game.ts
Normal file
179
Web/game-snake-poc/frontend/ts/game.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/game.ts
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
import init, { SnakeWasmGame } from "@snake-wasm";
|
||||||
|
import { webDebug, webInfo } from "./logging";
|
||||||
|
import { BrowserRuntimeProvenance, type SnakeInputSource } from "./provenance";
|
||||||
|
|
||||||
|
const FRAME_MILLIS = 16;
|
||||||
|
const MAX_FRAME_ELAPSED_MILLIS = 250;
|
||||||
|
|
||||||
|
export type SnakeDirection = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
export interface SnakeWebSession {
|
||||||
|
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void;
|
||||||
|
pause(): void;
|
||||||
|
resume(): void;
|
||||||
|
renderNow(): void;
|
||||||
|
refreshDeviceClass(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function color(red: number, green: number, blue: number): string {
|
||||||
|
return `rgb(${red} ${green} ${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas(canvas: HTMLCanvasElement): void {
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));
|
||||||
|
const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(
|
||||||
|
game: SnakeWasmGame,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
score: HTMLOutputElement,
|
||||||
|
length: HTMLOutputElement,
|
||||||
|
): void {
|
||||||
|
resizeCanvas(canvas);
|
||||||
|
context.fillStyle = color(game.background_red(), game.background_green(), game.background_blue());
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const count = game.rectangle_count();
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
context.fillStyle = color(game.rectangle_red(index), game.rectangle_green(index), game.rectangle_blue(index));
|
||||||
|
context.fillRect(
|
||||||
|
game.rectangle_x(index) * canvas.width,
|
||||||
|
game.rectangle_y(index) * canvas.height,
|
||||||
|
game.rectangle_width(index) * canvas.width,
|
||||||
|
game.rectangle_height(index) * canvas.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
score.value = `Score : ${game.score()}`;
|
||||||
|
length.value = `Longueur : ${game.length()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueDirection(game: SnakeWasmGame, direction: SnakeDirection): void {
|
||||||
|
switch (direction) {
|
||||||
|
case "left":
|
||||||
|
game.left();
|
||||||
|
return;
|
||||||
|
case "right":
|
||||||
|
game.right();
|
||||||
|
return;
|
||||||
|
case "up":
|
||||||
|
game.up();
|
||||||
|
return;
|
||||||
|
case "down":
|
||||||
|
game.down();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startGame(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
score: HTMLOutputElement,
|
||||||
|
length: HTMLOutputElement,
|
||||||
|
provenanceOutput: HTMLOutputElement,
|
||||||
|
): Promise<SnakeWebSession> {
|
||||||
|
await init();
|
||||||
|
webInfo("snake-web-runtime", "wasm_initialized");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("Le contexte Canvas 2D est indisponible.");
|
||||||
|
}
|
||||||
|
const renderingContext: CanvasRenderingContext2D = context;
|
||||||
|
const game = new SnakeWasmGame();
|
||||||
|
const provenance = new BrowserRuntimeProvenance(game, provenanceOutput);
|
||||||
|
let previous = performance.now();
|
||||||
|
let accumulator = 0;
|
||||||
|
let animationFrame: number | null = null;
|
||||||
|
let running = false;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function renderNow(): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render(game, canvas, renderingContext, score, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(): void {
|
||||||
|
if (!running || disposed || animationFrame !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animationFrame = window.requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now: number): void {
|
||||||
|
animationFrame = null;
|
||||||
|
if (!running || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = Math.min(Math.max(0, now - previous), MAX_FRAME_ELAPSED_MILLIS);
|
||||||
|
previous = now;
|
||||||
|
accumulator += elapsed;
|
||||||
|
while (accumulator >= FRAME_MILLIS) {
|
||||||
|
game.tick();
|
||||||
|
accumulator -= FRAME_MILLIS;
|
||||||
|
}
|
||||||
|
renderNow();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(): void {
|
||||||
|
if (!running || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = false;
|
||||||
|
accumulator = 0;
|
||||||
|
if (animationFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = null;
|
||||||
|
}
|
||||||
|
webDebug("snake-web-runtime", "runtime_paused");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume(): void {
|
||||||
|
if (running || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previous = performance.now();
|
||||||
|
accumulator = 0;
|
||||||
|
running = true;
|
||||||
|
schedule();
|
||||||
|
webDebug("snake-web-runtime", "runtime_resumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNow();
|
||||||
|
resume();
|
||||||
|
return {
|
||||||
|
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
provenance.recordInput(source);
|
||||||
|
queueDirection(game, direction);
|
||||||
|
},
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
renderNow,
|
||||||
|
refreshDeviceClass(): void {
|
||||||
|
if (!disposed) {
|
||||||
|
provenance.refreshDeviceClass();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose(): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pause();
|
||||||
|
disposed = true;
|
||||||
|
game.free();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
61
Web/game-snake-poc/frontend/ts/input.ts
Normal file
61
Web/game-snake-poc/frontend/ts/input.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/input.ts
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
import type { SnakeDirection, SnakeWebSession } from "./game";
|
||||||
|
import { webDebug } from "./logging";
|
||||||
|
|
||||||
|
const KEY_DIRECTIONS: Readonly<Record<string, SnakeDirection>> = {
|
||||||
|
arrowleft: "left",
|
||||||
|
a: "left",
|
||||||
|
q: "left",
|
||||||
|
arrowright: "right",
|
||||||
|
d: "right",
|
||||||
|
arrowup: "up",
|
||||||
|
w: "up",
|
||||||
|
z: "up",
|
||||||
|
arrowdown: "down",
|
||||||
|
s: "down",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buttonDirection(button: HTMLButtonElement): SnakeDirection | null {
|
||||||
|
const direction = button.dataset.direction;
|
||||||
|
if (direction === "left" || direction === "right" || direction === "up" || direction === "down") {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindInputs(session: SnakeWebSession, buttons: readonly HTMLButtonElement[]): void {
|
||||||
|
window.addEventListener("keydown", event => {
|
||||||
|
if (event.repeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const direction = KEY_DIRECTIONS[event.key.toLowerCase()];
|
||||||
|
if (direction === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
session.queueDirection(direction, "keyboard-mouse");
|
||||||
|
webDebug("snake-web-input", "direction", { direction, source: "keyboard-mouse" });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const button of buttons) {
|
||||||
|
const direction = buttonDirection(button);
|
||||||
|
if (direction === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
button.addEventListener("pointerdown", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const source = event.pointerType === "touch" ? "touch" : "keyboard-mouse";
|
||||||
|
session.queueDirection(direction, source);
|
||||||
|
webDebug("snake-web-input", "direction", { direction, source });
|
||||||
|
});
|
||||||
|
button.addEventListener("click", event => {
|
||||||
|
if (event.detail !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.queueDirection(direction, "keyboard-mouse");
|
||||||
|
webDebug("snake-web-input", "direction", { direction, source: "keyboard-mouse" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Web/game-snake-poc/frontend/ts/lifecycle.ts
Normal file
58
Web/game-snake-poc/frontend/ts/lifecycle.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/lifecycle.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import type { SnakeWebSession } from "./game";
|
||||||
|
import { webDebug, webInfo } from "./logging";
|
||||||
|
|
||||||
|
export interface SnakeLifecycleBinding {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindBrowserLifecycle(session: SnakeWebSession, stage: HTMLElement): SnakeLifecycleBinding {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
session.refreshDeviceClass();
|
||||||
|
session.renderNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function synchronizeVisibility(): void {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
session.pause();
|
||||||
|
webDebug("snake-web-lifecycle", "visibility_hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.resume();
|
||||||
|
session.renderNow();
|
||||||
|
webDebug("snake-web-lifecycle", "visibility_visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHide(event: PageTransitionEvent): void {
|
||||||
|
if (event.persisted) {
|
||||||
|
session.pause();
|
||||||
|
} else {
|
||||||
|
session.dispose();
|
||||||
|
}
|
||||||
|
webInfo("snake-web-lifecycle", "page_hide", { persisted: event.persisted });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageShow(event: PageTransitionEvent): void {
|
||||||
|
session.resume();
|
||||||
|
session.renderNow();
|
||||||
|
webInfo("snake-web-lifecycle", "page_show", { persisted: event.persisted });
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver.observe(stage);
|
||||||
|
document.addEventListener("visibilitychange", synchronizeVisibility);
|
||||||
|
window.addEventListener("pagehide", pageHide);
|
||||||
|
window.addEventListener("pageshow", pageShow);
|
||||||
|
synchronizeVisibility();
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispose(): void {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", synchronizeVisibility);
|
||||||
|
window.removeEventListener("pagehide", pageHide);
|
||||||
|
window.removeEventListener("pageshow", pageShow);
|
||||||
|
session.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
45
Web/game-snake-poc/frontend/ts/logging.ts
Normal file
45
Web/game-snake-poc/frontend/ts/logging.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/logging.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
export type WebLogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
export type WebLogFields = Readonly<Record<string, boolean | number | string | null>>;
|
||||||
|
|
||||||
|
type ConsoleMethod = (...items: unknown[]) => void;
|
||||||
|
|
||||||
|
function consoleMethod(level: WebLogLevel): ConsoleMethod {
|
||||||
|
if (level === "debug") {
|
||||||
|
return console.debug.bind(console);
|
||||||
|
}
|
||||||
|
if (level === "info") {
|
||||||
|
return console.info.bind(console);
|
||||||
|
}
|
||||||
|
if (level === "warn") {
|
||||||
|
return console.warn.bind(console);
|
||||||
|
}
|
||||||
|
return console.error.bind(console);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level: WebLogLevel, target: string, action: string, fields: WebLogFields = {}): void {
|
||||||
|
const event = {
|
||||||
|
target,
|
||||||
|
action,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
consoleMethod(level)(`[games.sasedev][${target}] ${action}`, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webDebug(target: string, action: string, fields: WebLogFields = {}): void {
|
||||||
|
emit("debug", target, action, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webInfo(target: string, action: string, fields: WebLogFields = {}): void {
|
||||||
|
emit("info", target, action, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webWarn(target: string, action: string, fields: WebLogFields = {}): void {
|
||||||
|
emit("warn", target, action, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webError(target: string, action: string, fields: WebLogFields = {}): void {
|
||||||
|
emit("error", target, action, fields);
|
||||||
|
}
|
||||||
66
Web/game-snake-poc/frontend/ts/main.ts
Normal file
66
Web/game-snake-poc/frontend/ts/main.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/main.ts
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
import ResizeObserver from "resize-observer-polyfill";
|
||||||
|
import "simplebar";
|
||||||
|
import { loadSnakeWebAssets } from "./assets";
|
||||||
|
import { startGame } from "./game";
|
||||||
|
import { bindInputs } from "./input";
|
||||||
|
import { bindBrowserLifecycle } from "./lifecycle";
|
||||||
|
import { webError, webInfo } from "./logging";
|
||||||
|
|
||||||
|
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||||
|
|
||||||
|
function requireElement<T extends Element>(selector: string): T {
|
||||||
|
const element = document.querySelector<T>(selector);
|
||||||
|
if (element === null) {
|
||||||
|
throw new Error(`Élément frontend requis absent : ${selector}`);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const canvas = requireElement<HTMLCanvasElement>("#game");
|
||||||
|
const stage = requireElement<HTMLElement>(".game-stage");
|
||||||
|
const score = requireElement<HTMLOutputElement>("#score");
|
||||||
|
const length = requireElement<HTMLOutputElement>("#length");
|
||||||
|
const provenance = requireElement<HTMLOutputElement>("#runtime-provenance");
|
||||||
|
const assets = requireElement<HTMLOutputElement>("#asset-status");
|
||||||
|
const status = requireElement<HTMLSpanElement>("#runtime-status");
|
||||||
|
const error = requireElement<HTMLParagraphElement>("#startup-error");
|
||||||
|
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-direction]"));
|
||||||
|
|
||||||
|
status.textContent = "Chargement assets…";
|
||||||
|
const assetMetadata = await loadSnakeWebAssets();
|
||||||
|
assets.value = `${assetMetadata.gameKind} / engine-v${assetMetadata.engineGeneration}`;
|
||||||
|
webInfo("snake-web-assets", "runtime_assets_loaded", {
|
||||||
|
engineGeneration: assetMetadata.engineGeneration,
|
||||||
|
game: assetMetadata.game,
|
||||||
|
gameKind: assetMetadata.gameKind,
|
||||||
|
});
|
||||||
|
|
||||||
|
status.textContent = "Chargement WASM…";
|
||||||
|
const session = await startGame(canvas, score, length, provenance);
|
||||||
|
bindInputs(session, buttons);
|
||||||
|
bindBrowserLifecycle(session, stage);
|
||||||
|
status.className = "badge text-bg-success";
|
||||||
|
status.textContent = "Prêt";
|
||||||
|
error.hidden = true;
|
||||||
|
canvas.focus();
|
||||||
|
webInfo("snake-web-main", "runtime_ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch(caughtError => {
|
||||||
|
const status = document.querySelector<HTMLSpanElement>("#runtime-status");
|
||||||
|
const error = document.querySelector<HTMLParagraphElement>("#startup-error");
|
||||||
|
const message = caughtError instanceof Error ? caughtError.stack ?? caughtError.message : String(caughtError);
|
||||||
|
if (status !== null) {
|
||||||
|
status.className = "badge text-bg-danger";
|
||||||
|
status.textContent = "Erreur";
|
||||||
|
}
|
||||||
|
if (error !== null) {
|
||||||
|
error.textContent = `Impossible de démarrer Snake : ${message}`;
|
||||||
|
error.hidden = false;
|
||||||
|
}
|
||||||
|
webError("snake-web-main", "runtime_start_failed", { message });
|
||||||
|
});
|
||||||
98
Web/game-snake-poc/frontend/ts/provenance.ts
Normal file
98
Web/game-snake-poc/frontend/ts/provenance.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// file: Web/game-snake-poc/frontend/ts/provenance.ts
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
import type { SnakeWasmGame } from "@snake-wasm";
|
||||||
|
import { webInfo } from "./logging";
|
||||||
|
|
||||||
|
export type SnakeInputSource = "keyboard-mouse" | "touch";
|
||||||
|
|
||||||
|
type DeviceClass = "desktop" | "phone" | "tablet" | "unknown";
|
||||||
|
type InputProfile = "keyboard-mouse" | "mixed" | "touch" | "unknown";
|
||||||
|
|
||||||
|
function detectedDeviceClass(): DeviceClass {
|
||||||
|
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
const finePointer = window.matchMedia("(pointer: fine)").matches;
|
||||||
|
if (!coarsePointer || finePointer) {
|
||||||
|
return "desktop";
|
||||||
|
}
|
||||||
|
const shortEdge = Math.min(window.screen.width, window.screen.height);
|
||||||
|
if (!Number.isFinite(shortEdge) || shortEdge <= 0) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
return shortEdge < 768 ? "phone" : "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputProfile(keyboardMouseObserved: boolean, touchObserved: boolean): InputProfile {
|
||||||
|
if (keyboardMouseObserved && touchObserved) {
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
if (keyboardMouseObserved) {
|
||||||
|
return "keyboard-mouse";
|
||||||
|
}
|
||||||
|
if (touchObserved) {
|
||||||
|
return "touch";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayLabel(game: SnakeWasmGame): string {
|
||||||
|
return [
|
||||||
|
game.provenance_platform_family(),
|
||||||
|
game.provenance_runtime_host(),
|
||||||
|
game.provenance_execution_model(),
|
||||||
|
game.provenance_device_class(),
|
||||||
|
game.provenance_input_profile(),
|
||||||
|
].join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserRuntimeProvenance {
|
||||||
|
private keyboardMouseObserved = false;
|
||||||
|
private touchObserved = false;
|
||||||
|
private deviceClass: DeviceClass = detectedDeviceClass();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly game: SnakeWasmGame,
|
||||||
|
private readonly output: HTMLOutputElement,
|
||||||
|
) {
|
||||||
|
this.synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public recordInput(source: SnakeInputSource): void {
|
||||||
|
if (source === "touch") {
|
||||||
|
if (this.touchObserved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.touchObserved = true;
|
||||||
|
} else {
|
||||||
|
if (this.keyboardMouseObserved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.keyboardMouseObserved = true;
|
||||||
|
}
|
||||||
|
this.synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public refreshDeviceClass(): void {
|
||||||
|
const nextDeviceClass = detectedDeviceClass();
|
||||||
|
if (nextDeviceClass === this.deviceClass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deviceClass = nextDeviceClass;
|
||||||
|
this.synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronize(): void {
|
||||||
|
const profile = inputProfile(this.keyboardMouseObserved, this.touchObserved);
|
||||||
|
if (!this.game.configure_runtime_provenance("web", "browser", this.deviceClass, profile)) {
|
||||||
|
throw new Error("La provenance navigateur n'a pas pu être configurée dans le bridge WASM.");
|
||||||
|
}
|
||||||
|
this.output.value = displayLabel(this.game);
|
||||||
|
webInfo("snake-web-provenance", "runtime_provenance", {
|
||||||
|
deviceClass: this.game.provenance_device_class(),
|
||||||
|
executionModel: this.game.provenance_execution_model(),
|
||||||
|
inputProfile: this.game.provenance_input_profile(),
|
||||||
|
platformFamily: this.game.provenance_platform_family(),
|
||||||
|
runtimeHost: this.game.provenance_runtime_host(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Web/game-snake-poc/package.json
Normal file
25
Web/game-snake-poc/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "game-snake-poc-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.3.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^7.3",
|
||||||
|
"bootstrap": "^5.3",
|
||||||
|
"resize-observer-polyfill": "^1.5",
|
||||||
|
"simplebar": "^6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bootstrap": "^5.2",
|
||||||
|
"@types/node": "^26.1",
|
||||||
|
"sass-embedded": "^1.102",
|
||||||
|
"typescript": "^7.0",
|
||||||
|
"vite": "^8.2",
|
||||||
|
"vite-plugin-static-copy": "^4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Web/game-snake-poc/tsconfig.json
Normal file
36
Web/game-snake-poc/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@snake-wasm": [
|
||||||
|
"../../../builds/sasedev-games/game-snake-poc-web/wasm/game_snake_poc_wasm.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"frontend",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
96
Web/game-snake-poc/vite.config.ts
Normal file
96
Web/game-snake-poc/vite.config.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// file: Web/game-snake-poc/vite.config.ts
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
import { NodePackageImporter } from "sass-embedded";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { defineConfig, normalizePath } from "vite";
|
||||||
|
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const repositoryRoot = normalizePath(resolve(appRoot, "../.."));
|
||||||
|
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||||
|
const externalBuildRoot = normalizePath(resolve(repositoryRoot, "../builds/sasedev-games/game-snake-poc-web"));
|
||||||
|
const wasmRoot = normalizePath(resolve(externalBuildRoot, "wasm"));
|
||||||
|
const wasmModule = normalizePath(resolve(wasmRoot, "game_snake_poc_wasm.js"));
|
||||||
|
const frontendDist = normalizePath(resolve(externalBuildRoot, "dist"));
|
||||||
|
const viteCacheDir = normalizePath(resolve(externalBuildRoot, "vite-cache"));
|
||||||
|
const commonRuntimeAsset = normalizePath(resolve(repositoryRoot, "assets/common/data/runtime.json"));
|
||||||
|
const gameRuntimeAsset = normalizePath(resolve(repositoryRoot, "assets/game-snake-poc/data/game.json"));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{ src: commonRuntimeAsset, dest: "common/data", rename: { stripBase: true } },
|
||||||
|
{ src: gameRuntimeAsset, dest: "game/data", rename: { stripBase: true } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
base: "./",
|
||||||
|
cacheDir: viteCacheDir,
|
||||||
|
clearScreen: false,
|
||||||
|
root: frontendRoot,
|
||||||
|
publicDir: false,
|
||||||
|
input: {
|
||||||
|
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@snake-wasm": wasmModule,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: frontendDist,
|
||||||
|
emptyOutDir: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "js/[name]-[hash].js",
|
||||||
|
chunkFileNames: "js/chunks/[name]-[hash].js",
|
||||||
|
assetFileNames: assetInfo => {
|
||||||
|
const originalName = assetInfo.names[0] ?? "";
|
||||||
|
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
if (extension === "css") {
|
||||||
|
return "css/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) {
|
||||||
|
return "fonts/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) {
|
||||||
|
return "imgs/[name][extname]";
|
||||||
|
}
|
||||||
|
if (extension === "wasm") {
|
||||||
|
return "wasm/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
return "otherassets/[name][extname]";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
quietDeps: true,
|
||||||
|
silenceDeprecations: ["import", "color-functions", "global-builtin"],
|
||||||
|
verbose: false,
|
||||||
|
importers: [new NodePackageImporter()],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 1434,
|
||||||
|
strictPort: true,
|
||||||
|
fs: {
|
||||||
|
allow: [appRoot, repositoryRoot, externalBuildRoot],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4174,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# file: crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-transport-fallback-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||||
|
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||||
|
game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }
|
||||||
|
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
325
crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
Normal file
325
crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
// file: crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Executable localhost smoke for WebTransport-first realtime composition with classified WebSocket fallback.
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const CLIENT_PAYLOAD: &[u8] = b"games.sasedev-fallback-client-smoke";
|
||||||
|
const FORCED_FALLBACK_DETAIL: &str = "forced WebTransport unavailability for fallback smoke";
|
||||||
|
const NON_FALLBACK_ENDPOINT: &str = "https://127.0.0.1:9/non-fallback";
|
||||||
|
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-fallback-server-smoke";
|
||||||
|
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::transport::fallback-smoke";
|
||||||
|
const UNUSED_WEBSOCKET_ENDPOINT: &str = "https://fallback-must-not-run.invalid/";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum SelectedTransport {
|
||||||
|
WebSocket,
|
||||||
|
WebTransport,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SelectedConnection<WebTransportConnection, WebSocketConnection> {
|
||||||
|
WebSocket(WebSocketConnection),
|
||||||
|
WebTransport(WebTransportConnection),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<WebTransportConnection, WebSocketConnection> SelectedConnection<WebTransportConnection, WebSocketConnection> {
|
||||||
|
fn transport(&self) -> SelectedTransport {
|
||||||
|
return match self {
|
||||||
|
Self::WebSocket(_) => SelectedTransport::WebSocket,
|
||||||
|
Self::WebTransport(_) => SelectedTransport::WebTransport,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> std::process::ExitCode {
|
||||||
|
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||||
|
std::result::Result::Ok(guard) => guard,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("failed to initialize realtime fallback smoke tracing: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, "realtime transport fallback smoke started");
|
||||||
|
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(())) => {
|
||||||
|
tracing::info!(target: TRACING_TARGET, "realtime transport fallback smoke passed");
|
||||||
|
println!("game-realtime-transport-fallback-smoke: PASS");
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "realtime transport fallback smoke failed");
|
||||||
|
eprintln!("game-realtime-transport-fallback-smoke: FAIL: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime transport fallback smoke timed out");
|
||||||
|
eprintln!("game-realtime-transport-fallback-smoke: FAIL: smoke timed out");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_smoke() -> std::result::Result<(), String> {
|
||||||
|
if let std::result::Result::Err(error) = run_webtransport_preferred_branch().await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = run_websocket_fallback_branch().await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = run_non_fallback_error_branch().await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_webtransport_preferred_branch() -> std::result::Result<(), String> {
|
||||||
|
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred identity generation failed: {error}")),
|
||||||
|
};
|
||||||
|
let certificate_hash = identity.certificate_hash().clone();
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
|
||||||
|
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let endpoint = format!("https://{}/fallback-preferred", listener.local_addr());
|
||||||
|
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred client configuration failed: {error}")),
|
||||||
|
};
|
||||||
|
let server = async {
|
||||||
|
let session = match listener.accept().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return session.accept_primary_connection().await;
|
||||||
|
};
|
||||||
|
let client = select_preferred_transport(
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
let session = match game_realtime_webtransport_lib::connect(&client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return session.open_primary_connection().await;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return game_realtime_websocket_lib::connect(UNUSED_WEBSOCKET_ENDPOINT).await;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (server_result, client_result) = tokio::join!(server, client);
|
||||||
|
let server_connection = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred server establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_connection = match client_result {
|
||||||
|
std::result::Result::Ok(SelectedConnection::WebTransport(value)) => value,
|
||||||
|
std::result::Result::Ok(SelectedConnection::WebSocket(_)) => {
|
||||||
|
return std::result::Result::Err(String::from("WebTransport preferred branch unexpectedly selected WebSocket"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred selection failed: {error}")),
|
||||||
|
};
|
||||||
|
if let std::result::Result::Err(error) = exercise_round_trip(client_connection, server_connection).await {
|
||||||
|
return std::result::Result::Err(format!("WebTransport preferred round-trip failed: {error}"));
|
||||||
|
}
|
||||||
|
tracing::info!(target: TRACING_TARGET, selected = "webtransport", "preferred transport branch passed");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_websocket_fallback_branch() -> std::result::Result<(), String> {
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let endpoint = format!("ws://{}/fallback", listener.local_addr());
|
||||||
|
let server = listener.accept();
|
||||||
|
let client = select_preferred_transport(
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<game_realtime_webtransport_lib::WebTransportConnection, game_realtime_transport_lib::TransportError>::Err(
|
||||||
|
game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Timeout, FORCED_FALLBACK_DETAIL),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return game_realtime_websocket_lib::connect(endpoint.as_str()).await;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (server_result, client_result) = tokio::join!(server, client);
|
||||||
|
let server_connection = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback server establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_connection = match client_result {
|
||||||
|
std::result::Result::Ok(SelectedConnection::WebSocket(value)) => value,
|
||||||
|
std::result::Result::Ok(SelectedConnection::WebTransport(_)) => {
|
||||||
|
return std::result::Result::Err(String::from("forced fallback branch unexpectedly selected WebTransport"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback selection failed: {error}")),
|
||||||
|
};
|
||||||
|
if let std::result::Result::Err(error) = exercise_round_trip(client_connection, server_connection).await {
|
||||||
|
return std::result::Result::Err(format!("WebSocket fallback round-trip failed: {error}"));
|
||||||
|
}
|
||||||
|
tracing::info!(target: TRACING_TARGET, selected = "websocket", "classified fallback branch passed");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_non_fallback_error_branch() -> std::result::Result<(), String> {
|
||||||
|
let certificate_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256([0_u8; 32]);
|
||||||
|
let transport = game_realtime_webtransport_lib::WebTransportConfig::default().with_connect_timeout(std::time::Duration::ZERO);
|
||||||
|
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(NON_FALLBACK_ENDPOINT, certificate_hash) {
|
||||||
|
std::result::Result::Ok(value) => value.with_transport_config(transport),
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(format!("non-fallback WebTransport config construction failed unexpectedly: {error}"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let result = select_preferred_transport(
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
let session = match game_realtime_webtransport_lib::connect(&client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return session.open_primary_connection().await;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return game_realtime_websocket_lib::connect("ws://127.0.0.1:1/non-fallback-must-not-run").await;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Err(error)
|
||||||
|
if error.kind() == game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration
|
||||||
|
&& error.detail() == "connect_timeout must be greater than zero" =>
|
||||||
|
{
|
||||||
|
tracing::info!(target: TRACING_TARGET, kind = %error.kind(), "non-fallback error remained visible");
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(format!("non-fallback branch returned the wrong visible error: {error}")),
|
||||||
|
std::result::Result::Ok(selected) => std::result::Result::Err(format!("non-fallback branch unexpectedly selected {:?}", selected.transport())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn select_preferred_transport<WebTransportConnection, WebSocketConnection, WebTransportAttempt, WebTransportFuture, WebSocketAttempt, WebSocketFuture>(
|
||||||
|
webtransport_attempt: WebTransportAttempt,
|
||||||
|
websocket_attempt: WebSocketAttempt,
|
||||||
|
) -> std::result::Result<SelectedConnection<WebTransportConnection, WebSocketConnection>, game_realtime_transport_lib::TransportError>
|
||||||
|
where
|
||||||
|
WebTransportAttempt: std::ops::FnOnce() -> WebTransportFuture,
|
||||||
|
WebTransportFuture: core::future::Future<Output = std::result::Result<WebTransportConnection, game_realtime_transport_lib::TransportError>>,
|
||||||
|
WebSocketAttempt: std::ops::FnOnce() -> WebSocketFuture,
|
||||||
|
WebSocketFuture: core::future::Future<Output = std::result::Result<WebSocketConnection, game_realtime_transport_lib::TransportError>>,
|
||||||
|
{
|
||||||
|
let primary_result = webtransport_attempt().await;
|
||||||
|
return match primary_result {
|
||||||
|
std::result::Result::Ok(connection) => std::result::Result::Ok(SelectedConnection::WebTransport(connection)),
|
||||||
|
std::result::Result::Err(primary_error) if is_fallback_eligible(primary_error.kind()) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: TRACING_TARGET,
|
||||||
|
primary_kind = %primary_error.kind(),
|
||||||
|
primary_detail = primary_error.detail(),
|
||||||
|
"WebTransport unavailable; attempting classified WebSocket fallback"
|
||||||
|
);
|
||||||
|
return match websocket_attempt().await {
|
||||||
|
std::result::Result::Ok(connection) => std::result::Result::Ok(SelectedConnection::WebSocket(connection)),
|
||||||
|
std::result::Result::Err(fallback_error) => std::result::Result::Err(game_realtime_transport_lib::TransportError::new(
|
||||||
|
fallback_error.kind(),
|
||||||
|
format!("WebTransport attempt failed ({primary_error}); WebSocket fallback failed ({fallback_error})"),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_fallback_eligible(kind: game_realtime_transport_lib::TransportErrorKind) -> bool {
|
||||||
|
return kind == game_realtime_transport_lib::TransportErrorKind::Timeout || kind == game_realtime_transport_lib::TransportErrorKind::Io;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exercise_round_trip<ClientConnection, ServerConnection>(
|
||||||
|
client_connection: ClientConnection,
|
||||||
|
server_connection: ServerConnection,
|
||||||
|
) -> std::result::Result<(), String>
|
||||||
|
where
|
||||||
|
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||||
|
ClientConnection::Receiver: game_realtime_transport_lib::RealtimeReceiver,
|
||||||
|
ClientConnection::Sender: game_realtime_transport_lib::RealtimeSender,
|
||||||
|
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||||
|
ServerConnection::Receiver: game_realtime_transport_lib::RealtimeReceiver,
|
||||||
|
ServerConnection::Sender: game_realtime_transport_lib::RealtimeSender,
|
||||||
|
{
|
||||||
|
let (mut client_sender, mut client_receiver) = client_connection.split();
|
||||||
|
let (mut server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec())).await {
|
||||||
|
return std::result::Result::Err(format!("client send failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_received = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(server_received, CLIENT_PAYLOAD) {
|
||||||
|
return std::result::Result::Err(String::from("server did not receive the expected client payload"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec())).await {
|
||||||
|
return std::result::Result::Err(format!("server send failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_received = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(client_received, SERVER_PAYLOAD) {
|
||||||
|
return std::result::Result::Err(String::from("client did not receive the expected server payload"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = client_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("client close failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_close = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("server did not observe the client close"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("server close failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_close = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("client did not observe the server close"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||||
|
return match receive {
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/fallback.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// file: crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_policy_is_narrow_and_excludes_ambiguous_connect_failures() {
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Connect));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Bind));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Accept));
|
||||||
|
assert!(super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Timeout));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::MessageTooLarge));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Backpressure));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Closed));
|
||||||
|
assert!(super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Io));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Protocol));
|
||||||
|
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Aborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn selector_prefers_webtransport_and_falls_back_only_for_classified_errors() {
|
||||||
|
let preferred = super::select_preferred_transport(
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(7);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(test_error("fallback must not run"));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(preferred, std::result::Result::Ok(super::SelectedConnection::WebTransport(7))), "WebTransport success must remain preferred");
|
||||||
|
let fallback = super::select_preferred_transport(
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Timeout,
|
||||||
|
"forced timeout",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(9);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(fallback, std::result::Result::Ok(super::SelectedConnection::WebSocket(9))), "classified timeout must select WebSocket fallback");
|
||||||
|
let non_fallback = super::select_preferred_transport(
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(test_error("visible primary error"));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
return async {
|
||||||
|
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(11);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&non_fallback,
|
||||||
|
std::result::Result::Err(error)
|
||||||
|
if error.kind() == game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration
|
||||||
|
&& error.detail() == "visible primary error"
|
||||||
|
),
|
||||||
|
"non-fallback errors must remain visible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_error(detail: &str) -> game_realtime_transport_lib::TransportError {
|
||||||
|
return game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
||||||
|
}
|
||||||
20
crates/apps/game-realtime-transport-measure/Cargo.toml
Normal file
20
crates/apps/game-realtime-transport-measure/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# file: crates/apps/game-realtime-transport-measure/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-transport-measure"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||||
|
game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }
|
||||||
|
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
513
crates/apps/game-realtime-transport-measure/src/main.rs
Normal file
513
crates/apps/game-realtime-transport-measure/src/main.rs
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
// file: crates/apps/game-realtime-transport-measure/src/main.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Bounded localhost characterization tool for WebSocket and WebTransport realtime backends.
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const ACK_PAYLOAD: &[u8] = b"ack";
|
||||||
|
const DATAGRAM_PAYLOAD_BYTES: usize = 256;
|
||||||
|
const DATAGRAM_RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
|
||||||
|
const DATAGRAM_SAMPLES: usize = 64;
|
||||||
|
const ESTABLISHMENT_SAMPLES: usize = 8;
|
||||||
|
const IN_FLIGHT_MESSAGES: usize = 64;
|
||||||
|
const IN_FLIGHT_PAYLOAD_BYTES: usize = 1024;
|
||||||
|
const MEASUREMENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
const RTT_PAYLOAD_BYTES: usize = 32;
|
||||||
|
const RTT_SAMPLES: usize = 128;
|
||||||
|
const RTT_WARMUP: usize = 16;
|
||||||
|
const THROUGHPUT_MESSAGES: usize = 128;
|
||||||
|
const THROUGHPUT_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct LatencySummary {
|
||||||
|
min_us: u128,
|
||||||
|
median_us: u128,
|
||||||
|
p95_us: u128,
|
||||||
|
max_us: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferSummary {
|
||||||
|
elapsed: std::time::Duration,
|
||||||
|
messages: usize,
|
||||||
|
payload_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DatagramSummary {
|
||||||
|
elapsed: std::time::Duration,
|
||||||
|
attempted: usize,
|
||||||
|
received: usize,
|
||||||
|
payload_bytes: usize,
|
||||||
|
client_max: usize,
|
||||||
|
server_max: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> std::process::ExitCode {
|
||||||
|
let result = tokio::time::timeout(MEASUREMENT_TIMEOUT, run_measurements()).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(())) => {
|
||||||
|
println!("CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities");
|
||||||
|
println!("game-realtime-transport-measure: PASS");
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||||
|
eprintln!("game-realtime-transport-measure: FAIL: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
eprintln!("game-realtime-transport-measure: FAIL: measurement timeout after {} ms", MEASUREMENT_TIMEOUT.as_millis());
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_measurements() -> std::result::Result<(), String> {
|
||||||
|
let websocket_listener = match game_realtime_websocket_lib::WebSocketListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], 0))).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket measurement listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let websocket_endpoint = format!("ws://{}/measure", websocket_listener.local_addr());
|
||||||
|
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport measurement identity generation failed: {error}")),
|
||||||
|
};
|
||||||
|
let certificate_hash = identity.certificate_hash().clone();
|
||||||
|
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
|
||||||
|
let mut webtransport_listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport measurement listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let webtransport_endpoint = format!("https://{}/measure", webtransport_listener.local_addr());
|
||||||
|
let webtransport_client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(webtransport_endpoint.as_str(), certificate_hash) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport measurement client configuration failed: {error}")),
|
||||||
|
};
|
||||||
|
let websocket_establishment = match measure_websocket_establishment(&websocket_listener, websocket_endpoint.as_str()).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_latency("websocket", "establishment", ESTABLISHMENT_SAMPLES, 0, websocket_establishment);
|
||||||
|
let webtransport_establishment = match measure_webtransport_establishment(&mut webtransport_listener, &webtransport_client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_latency("webtransport", "establishment", ESTABLISHMENT_SAMPLES, 0, webtransport_establishment);
|
||||||
|
let (websocket_client, websocket_server) = match establish_websocket_pair(&websocket_listener, websocket_endpoint.as_str()).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let websocket_rtt = match measure_rtt(websocket_client, websocket_server).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_latency("websocket", "rtt", RTT_SAMPLES, RTT_PAYLOAD_BYTES, websocket_rtt);
|
||||||
|
let (webtransport_client, webtransport_server) = match establish_webtransport_pair(&mut webtransport_listener, &webtransport_client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let webtransport_rtt = match measure_rtt(webtransport_client, webtransport_server).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_latency("webtransport", "rtt", RTT_SAMPLES, RTT_PAYLOAD_BYTES, webtransport_rtt);
|
||||||
|
let (websocket_client, websocket_server) = match establish_websocket_pair(&websocket_listener, websocket_endpoint.as_str()).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let websocket_throughput = match measure_transfer(websocket_client, websocket_server, THROUGHPUT_MESSAGES, THROUGHPUT_PAYLOAD_BYTES).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_transfer("websocket", "throughput", &websocket_throughput);
|
||||||
|
let (webtransport_client, webtransport_server) = match establish_webtransport_pair(&mut webtransport_listener, &webtransport_client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let webtransport_throughput = match measure_transfer(webtransport_client, webtransport_server, THROUGHPUT_MESSAGES, THROUGHPUT_PAYLOAD_BYTES).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_transfer("webtransport", "throughput", &webtransport_throughput);
|
||||||
|
let (websocket_client, websocket_server) = match establish_websocket_pair(&websocket_listener, websocket_endpoint.as_str()).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let websocket_in_flight = match measure_transfer(websocket_client, websocket_server, IN_FLIGHT_MESSAGES, IN_FLIGHT_PAYLOAD_BYTES).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_transfer("websocket", "in_flight_window", &websocket_in_flight);
|
||||||
|
let (webtransport_client, webtransport_server) = match establish_webtransport_pair(&mut webtransport_listener, &webtransport_client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let webtransport_in_flight = match measure_transfer(webtransport_client, webtransport_server, IN_FLIGHT_MESSAGES, IN_FLIGHT_PAYLOAD_BYTES).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_transfer("webtransport", "in_flight_window", &webtransport_in_flight);
|
||||||
|
let datagram = match measure_webtransport_datagrams(&mut webtransport_listener, &webtransport_client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
print_datagram(&datagram);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn measure_websocket_establishment(
|
||||||
|
listener: &game_realtime_websocket_lib::WebSocketListener,
|
||||||
|
endpoint: &str,
|
||||||
|
) -> std::result::Result<LatencySummary, String> {
|
||||||
|
let mut samples = Vec::with_capacity(ESTABLISHMENT_SAMPLES);
|
||||||
|
for _ in 0..ESTABLISHMENT_SAMPLES {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let (server, client) = tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint));
|
||||||
|
let _server = match server {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket establishment server failed: {error}")),
|
||||||
|
};
|
||||||
|
let _client = match client {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket establishment client failed: {error}")),
|
||||||
|
};
|
||||||
|
samples.push(started.elapsed());
|
||||||
|
}
|
||||||
|
return summarize_latency(samples.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn measure_webtransport_establishment(
|
||||||
|
listener: &mut game_realtime_webtransport_lib::WebTransportListener,
|
||||||
|
client_config: &game_realtime_webtransport_lib::WebTransportClientConfig,
|
||||||
|
) -> std::result::Result<LatencySummary, String> {
|
||||||
|
let mut samples = Vec::with_capacity(ESTABLISHMENT_SAMPLES);
|
||||||
|
for _ in 0..ESTABLISHMENT_SAMPLES {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let _pair = match establish_webtransport_pair(listener, client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
samples.push(started.elapsed());
|
||||||
|
}
|
||||||
|
return summarize_latency(samples.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn establish_websocket_pair(
|
||||||
|
listener: &game_realtime_websocket_lib::WebSocketListener,
|
||||||
|
endpoint: &str,
|
||||||
|
) -> std::result::Result<(game_realtime_websocket_lib::WebSocketConnection, game_realtime_websocket_lib::WebSocketConnection), String> {
|
||||||
|
let (server, client) = tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint));
|
||||||
|
let server = match server {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket server establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
let client = match client {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket client establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok((client, server));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn establish_webtransport_pair(
|
||||||
|
listener: &mut game_realtime_webtransport_lib::WebTransportListener,
|
||||||
|
client_config: &game_realtime_webtransport_lib::WebTransportClientConfig,
|
||||||
|
) -> std::result::Result<(game_realtime_webtransport_lib::WebTransportConnection, game_realtime_webtransport_lib::WebTransportConnection), String> {
|
||||||
|
let server = async {
|
||||||
|
let session = match listener.accept().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport server session establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
return match session.accept_primary_connection().await {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(format!("WebTransport server primary stream establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let client = async {
|
||||||
|
let session = match game_realtime_webtransport_lib::connect(client_config).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport client session establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
return match session.open_primary_connection().await {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(format!("WebTransport client primary stream establishment failed: {error}")),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let (server, client) = tokio::join!(server, client);
|
||||||
|
return match (client, server) {
|
||||||
|
(std::result::Result::Ok(client), std::result::Result::Ok(server)) => std::result::Result::Ok((client, server)),
|
||||||
|
(std::result::Result::Err(error), _) => std::result::Result::Err(error),
|
||||||
|
(_, std::result::Result::Err(error)) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn measure_rtt<ClientConnection, ServerConnection>(client: ClientConnection, server: ServerConnection) -> std::result::Result<LatencySummary, String>
|
||||||
|
where
|
||||||
|
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||||
|
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||||
|
{
|
||||||
|
let total_messages = RTT_WARMUP + RTT_SAMPLES;
|
||||||
|
let payload = vec![0x52_u8; RTT_PAYLOAD_BYTES];
|
||||||
|
let (mut client_sender, mut client_receiver) = client.split();
|
||||||
|
let (mut server_sender, mut server_receiver) = server.split();
|
||||||
|
let server = async move {
|
||||||
|
for _ in 0..total_messages {
|
||||||
|
let message = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
|
||||||
|
return std::result::Result::Err(String::from("RTT server observed an early clean close"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT server receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if let std::result::Result::Err(error) = server_sender.send(message).await {
|
||||||
|
return std::result::Result::Err(format!("RTT server echo failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let client_close = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT server close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("RTT server did not observe the client close"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("RTT server close failed: {error}"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
};
|
||||||
|
let client = async move {
|
||||||
|
let mut samples = Vec::with_capacity(RTT_SAMPLES);
|
||||||
|
for index in 0..total_messages {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(payload.clone())).await {
|
||||||
|
return std::result::Result::Err(format!("RTT client send failed: {error}"));
|
||||||
|
}
|
||||||
|
let response = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
|
||||||
|
return std::result::Result::Err(String::from("RTT client observed an early clean close"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT client receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if response.as_bytes() != payload.as_slice() {
|
||||||
|
return std::result::Result::Err(String::from("RTT echo payload mismatch"));
|
||||||
|
}
|
||||||
|
if index >= RTT_WARMUP {
|
||||||
|
samples.push(started.elapsed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = client_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("RTT client close failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_close = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT client close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("RTT client did not observe the server close"));
|
||||||
|
}
|
||||||
|
return summarize_latency(samples.as_slice());
|
||||||
|
};
|
||||||
|
let (server, client) = tokio::join!(server, client);
|
||||||
|
match server {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn measure_transfer<ClientConnection, ServerConnection>(
|
||||||
|
client: ClientConnection,
|
||||||
|
server: ServerConnection,
|
||||||
|
messages: usize,
|
||||||
|
payload_bytes: usize,
|
||||||
|
) -> std::result::Result<TransferSummary, String>
|
||||||
|
where
|
||||||
|
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||||
|
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||||
|
{
|
||||||
|
let payload = vec![0x54_u8; payload_bytes];
|
||||||
|
let (mut client_sender, mut client_receiver) = client.split();
|
||||||
|
let (mut server_sender, mut server_receiver) = server.split();
|
||||||
|
let server = async move {
|
||||||
|
for _ in 0..messages {
|
||||||
|
let received = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
|
||||||
|
return std::result::Result::Err(String::from("transfer server observed an early clean close"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer server receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if received.len() != payload_bytes {
|
||||||
|
return std::result::Result::Err(format!("transfer server payload size mismatch: expected {payload_bytes}, got {}", received.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(ACK_PAYLOAD.to_vec())).await {
|
||||||
|
return std::result::Result::Err(format!("transfer server ACK failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_close = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer server close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("transfer server did not observe the client close"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("transfer server close failed: {error}"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
};
|
||||||
|
let client = async move {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
for _ in 0..messages {
|
||||||
|
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(payload.clone())).await {
|
||||||
|
return std::result::Result::Err(format!("transfer client send failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ack = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
|
||||||
|
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
|
||||||
|
return std::result::Result::Err(String::from("transfer client observed close before ACK"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer client ACK receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if ack.as_bytes() != ACK_PAYLOAD {
|
||||||
|
return std::result::Result::Err(String::from("transfer ACK payload mismatch"));
|
||||||
|
}
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
if let std::result::Result::Err(error) = client_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("transfer client close failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_close = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer client close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("transfer client did not observe the server close"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(TransferSummary { elapsed, messages, payload_bytes });
|
||||||
|
};
|
||||||
|
let (server, client) = tokio::join!(server, client);
|
||||||
|
match server {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn measure_webtransport_datagrams(
|
||||||
|
listener: &mut game_realtime_webtransport_lib::WebTransportListener,
|
||||||
|
client_config: &game_realtime_webtransport_lib::WebTransportClientConfig,
|
||||||
|
) -> std::result::Result<DatagramSummary, String> {
|
||||||
|
let (server, client) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(client_config));
|
||||||
|
let server = match server {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("datagram measurement server session failed: {error}")),
|
||||||
|
};
|
||||||
|
let client = match client {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("datagram measurement client session failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_max = client.max_datagram_size();
|
||||||
|
let server_max = server.max_datagram_size();
|
||||||
|
let negotiated_max = core::cmp::min(client_max, server_max);
|
||||||
|
if negotiated_max == 0 {
|
||||||
|
return std::result::Result::Err(String::from("datagram measurement negotiated a zero-byte payload bound"));
|
||||||
|
}
|
||||||
|
let payload_bytes = core::cmp::min(DATAGRAM_PAYLOAD_BYTES, negotiated_max);
|
||||||
|
let payload = vec![0x44_u8; payload_bytes];
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
for _ in 0..DATAGRAM_SAMPLES {
|
||||||
|
if let std::result::Result::Err(error) = client.send_datagram(payload.as_slice()) {
|
||||||
|
return std::result::Result::Err(format!("datagram measurement send failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut received = 0_usize;
|
||||||
|
while received < DATAGRAM_SAMPLES {
|
||||||
|
let datagram = tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, server.receive_datagram()).await;
|
||||||
|
match datagram {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(value)) => {
|
||||||
|
if value.len() != payload_bytes {
|
||||||
|
return std::result::Result::Err(format!("datagram measurement payload size mismatch: expected {payload_bytes}, got {}", value.len()));
|
||||||
|
}
|
||||||
|
received += 1;
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||||
|
return std::result::Result::Err(format!("datagram measurement receive failed: {error}"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(DatagramSummary {
|
||||||
|
elapsed: started.elapsed(),
|
||||||
|
attempted: DATAGRAM_SAMPLES,
|
||||||
|
received,
|
||||||
|
payload_bytes,
|
||||||
|
client_max,
|
||||||
|
server_max,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_latency(samples: &[std::time::Duration]) -> std::result::Result<LatencySummary, String> {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return std::result::Result::Err(String::from("latency summary requires at least one sample"));
|
||||||
|
}
|
||||||
|
let mut values: Vec<u128> = samples.iter().map(std::time::Duration::as_micros).collect();
|
||||||
|
values.sort_unstable();
|
||||||
|
let min_us = match values.first() {
|
||||||
|
Some(value) => *value,
|
||||||
|
None => return std::result::Result::Err(String::from("latency summary unexpectedly lost all samples")),
|
||||||
|
};
|
||||||
|
let max_us = match values.last() {
|
||||||
|
Some(value) => *value,
|
||||||
|
None => return std::result::Result::Err(String::from("latency summary unexpectedly lost all samples")),
|
||||||
|
};
|
||||||
|
let median_us = match values.get(values.len() / 2) {
|
||||||
|
Some(value) => *value,
|
||||||
|
None => return std::result::Result::Err(String::from("latency median index is invalid")),
|
||||||
|
};
|
||||||
|
let p95_rank = values.len().saturating_mul(95).div_ceil(100);
|
||||||
|
let p95_index = p95_rank.saturating_sub(1);
|
||||||
|
let p95_us = match values.get(p95_index) {
|
||||||
|
Some(value) => *value,
|
||||||
|
None => return std::result::Result::Err(String::from("latency p95 index is invalid")),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(LatencySummary { min_us, median_us, p95_us, max_us });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_latency(transport: &str, metric: &str, samples: usize, payload_bytes: usize, summary: LatencySummary) {
|
||||||
|
println!(
|
||||||
|
"MEASURE transport={transport} metric={metric} samples={samples} payload_bytes={payload_bytes} min_us={} median_us={} p95_us={} max_us={}",
|
||||||
|
summary.min_us, summary.median_us, summary.p95_us, summary.max_us
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_transfer(transport: &str, metric: &str, summary: &TransferSummary) {
|
||||||
|
let elapsed_seconds = summary.elapsed.as_secs_f64();
|
||||||
|
let total_bytes = summary.messages.saturating_mul(summary.payload_bytes);
|
||||||
|
let mib_per_second = if elapsed_seconds > 0.0 { (total_bytes as f64 / (1024.0 * 1024.0)) / elapsed_seconds } else { 0.0 };
|
||||||
|
let messages_per_second = if elapsed_seconds > 0.0 { summary.messages as f64 / elapsed_seconds } else { 0.0 };
|
||||||
|
println!(
|
||||||
|
"MEASURE transport={transport} metric={metric} messages={} payload_bytes={} total_bytes={total_bytes} elapsed_us={} mib_per_s={mib_per_second:.3} messages_per_s={messages_per_second:.3}",
|
||||||
|
summary.messages,
|
||||||
|
summary.payload_bytes,
|
||||||
|
summary.elapsed.as_micros()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_datagram(summary: &DatagramSummary) {
|
||||||
|
let ratio = if summary.attempted == 0 { 0.0 } else { summary.received as f64 / summary.attempted as f64 };
|
||||||
|
println!(
|
||||||
|
"MEASURE transport=webtransport-datagram metric=bounded_burst attempted={} received={} receive_ratio={ratio:.6} payload_bytes={} client_max={} server_max={} elapsed_us={}",
|
||||||
|
summary.attempted,
|
||||||
|
summary.received,
|
||||||
|
summary.payload_bytes,
|
||||||
|
summary.client_max,
|
||||||
|
summary.server_max,
|
||||||
|
summary.elapsed.as_micros()
|
||||||
|
);
|
||||||
|
}
|
||||||
21
crates/apps/game-realtime-websocket-smoke/Cargo.toml
Normal file
21
crates/apps/game-realtime-websocket-smoke/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# file: crates/apps/game-realtime-websocket-smoke/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-websocket-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||||
|
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||||
|
game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
108
crates/apps/game-realtime-websocket-smoke/src/main.rs
Normal file
108
crates/apps/game-realtime-websocket-smoke/src/main.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// file: crates/apps/game-realtime-websocket-smoke/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Executable localhost smoke for the public games.sasedev WebSocket realtime transport path.
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const CLIENT_PAYLOAD: &[u8] = b"games.sasedev-client-smoke";
|
||||||
|
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-server-smoke";
|
||||||
|
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::websocket::smoke";
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> std::process::ExitCode {
|
||||||
|
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||||
|
std::result::Result::Ok(guard) => guard,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("failed to initialize realtime WebSocket smoke tracing: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, "realtime WebSocket smoke started");
|
||||||
|
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(())) => {
|
||||||
|
tracing::info!(target: TRACING_TARGET, "realtime WebSocket smoke passed");
|
||||||
|
println!("game-realtime-websocket-smoke: PASS");
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "realtime WebSocket smoke failed");
|
||||||
|
eprintln!("game-realtime-websocket-smoke: FAIL: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime WebSocket smoke timed out");
|
||||||
|
eprintln!("game-realtime-websocket-smoke: FAIL: smoke timed out");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_smoke() -> std::result::Result<(), String> {
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let endpoint = format!("ws://{}/", listener.local_addr());
|
||||||
|
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "loopback endpoint bound");
|
||||||
|
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint.as_str()));
|
||||||
|
let server_connection = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server accept failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_connection = match client_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client connect failed: {error}")),
|
||||||
|
};
|
||||||
|
let (mut server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
let (mut client_sender, mut client_receiver) = client_connection.split();
|
||||||
|
let client_message = game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec());
|
||||||
|
if let std::result::Result::Err(error) = client_sender.send(client_message).await {
|
||||||
|
return std::result::Result::Err(format!("client send failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_received = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(server_received, CLIENT_PAYLOAD) {
|
||||||
|
return std::result::Result::Err(String::from("server did not receive the expected client payload"));
|
||||||
|
}
|
||||||
|
let server_message = game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec());
|
||||||
|
if let std::result::Result::Err(error) = server_sender.send(server_message).await {
|
||||||
|
return std::result::Result::Err(format!("server send failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_received = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(client_received, SERVER_PAYLOAD) {
|
||||||
|
return std::result::Result::Err(String::from("client did not receive the expected server payload"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = client_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("client close failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_close = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("server did not observe the client close handshake"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||||
|
return match receive {
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# file: crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-webtransport-browser-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||||
|
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wasm-bindgen.workspace = true
|
||||||
|
wasm-bindgen-futures.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/browser.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const CERTIFICATE_HASH_HEX_SIZE: usize = 64;
|
||||||
|
const CERTIFICATE_HASH_SIZE: usize = 32;
|
||||||
|
|
||||||
|
/// Runs one real-browser WebTransport round-trip against the supplied pinned endpoint.
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub async fn run_browser_smoke(endpoint: String, certificate_sha256_hex: String) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
let certificate_hash = match decode_certificate_hash(certificate_sha256_hex.as_str()) {
|
||||||
|
Ok(value) => game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256(value),
|
||||||
|
Err(error) => return Err(wasm_bindgen::JsValue::from_str(error.as_str())),
|
||||||
|
};
|
||||||
|
let transport = game_realtime_webtransport_lib::WebTransportConfig::default()
|
||||||
|
.with_connect_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.with_primary_stream_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.with_send_timeout(std::time::Duration::from_secs(5));
|
||||||
|
let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||||
|
Ok(value) => value.with_transport_config(transport),
|
||||||
|
Err(error) => return Err(js_transport_error("browser client configuration failed", error)),
|
||||||
|
};
|
||||||
|
let session = match game_realtime_webtransport_lib::connect(&config).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(js_transport_error("browser client connection failed", error)),
|
||||||
|
};
|
||||||
|
let connection = match session.open_primary_connection().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(js_transport_error("browser primary stream open failed", error)),
|
||||||
|
};
|
||||||
|
let (mut sender, mut receiver) = connection.split();
|
||||||
|
if let Err(error) = sender.send(game_realtime_transport_lib::TransportMessage::new(crate::shared::BROWSER_PAYLOAD.to_vec())).await {
|
||||||
|
return Err(js_transport_error("browser send failed", error));
|
||||||
|
}
|
||||||
|
let received = match receiver.receive().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(js_transport_error("browser receive failed", error)),
|
||||||
|
};
|
||||||
|
if !receive_matches(received, crate::shared::SERVER_PAYLOAD) {
|
||||||
|
return Err(wasm_bindgen::JsValue::from_str("browser did not receive the expected server payload"));
|
||||||
|
}
|
||||||
|
if let Err(error) = sender.close().await {
|
||||||
|
return Err(js_transport_error("browser close failed", error));
|
||||||
|
}
|
||||||
|
let remote_close = match receiver.receive().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(js_transport_error("browser close observation failed", error)),
|
||||||
|
};
|
||||||
|
if remote_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return Err(wasm_bindgen::JsValue::from_str("browser did not observe the server FIN"));
|
||||||
|
}
|
||||||
|
return Ok(String::from("game-realtime-webtransport-browser-smoke: PASS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_certificate_hash(value: &str) -> Result<[u8; CERTIFICATE_HASH_SIZE], String> {
|
||||||
|
let encoded = value.as_bytes();
|
||||||
|
if encoded.len() != CERTIFICATE_HASH_HEX_SIZE {
|
||||||
|
return Err(String::from("certificate SHA-256 hash must contain exactly 64 hexadecimal characters"));
|
||||||
|
}
|
||||||
|
let mut decoded = [0_u8; CERTIFICATE_HASH_SIZE];
|
||||||
|
for index in 0..CERTIFICATE_HASH_SIZE {
|
||||||
|
let high = match hex_nibble(encoded[index * 2]) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Err(String::from("certificate SHA-256 hash contains a non-hexadecimal character")),
|
||||||
|
};
|
||||||
|
let low = match hex_nibble(encoded[index * 2 + 1]) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Err(String::from("certificate SHA-256 hash contains a non-hexadecimal character")),
|
||||||
|
};
|
||||||
|
decoded[index] = (high << 4) | low;
|
||||||
|
}
|
||||||
|
return Ok(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_nibble(value: u8) -> Option<u8> {
|
||||||
|
return match value {
|
||||||
|
b'0'..=b'9' => Some(value - b'0'),
|
||||||
|
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_transport_error(context: &str, error: game_realtime_transport_lib::TransportError) -> wasm_bindgen::JsValue {
|
||||||
|
return wasm_bindgen::JsValue::from_str(format!("{context}: {error}").as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||||
|
return match receive {
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! WebAssembly adapter for the real-browser WebTransport smoke.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod browser;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod shared;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of the browser smoke entry point exposed through wasm-bindgen.
|
||||||
|
pub use self::browser::run_browser_smoke;
|
||||||
144
crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
Normal file
144
crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Native peer for the real-browser WebTransport smoke.
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod shared;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
const BROWSER_HOST_URL: &str = "http://127.0.0.1:1435/main.html";
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
const BROWSER_SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::webtransport::browser-smoke";
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> std::process::ExitCode {
|
||||||
|
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||||
|
std::result::Result::Ok(guard) => guard,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("failed to initialize browser WebTransport smoke tracing: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let prepared = match prepare_listener() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "browser WebTransport smoke preparation failed");
|
||||||
|
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (mut listener, endpoint, certificate_hash_hex) = prepared;
|
||||||
|
let browser_url = format!("{BROWSER_HOST_URL}?endpoint={endpoint}&sha256={certificate_hash_hex}");
|
||||||
|
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "browser WebTransport smoke peer ready");
|
||||||
|
println!("game-realtime-webtransport-browser-smoke: ENDPOINT={endpoint}");
|
||||||
|
println!("game-realtime-webtransport-browser-smoke: CERT_SHA256={certificate_hash_hex}");
|
||||||
|
println!("game-realtime-webtransport-browser-smoke: OPEN={browser_url}");
|
||||||
|
let result = tokio::time::timeout(BROWSER_SMOKE_TIMEOUT, serve_browser(&mut listener)).await;
|
||||||
|
return match result {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
tracing::info!(target: TRACING_TARGET, "browser WebTransport smoke passed");
|
||||||
|
println!("game-realtime-webtransport-browser-smoke: PASS");
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
},
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "browser WebTransport smoke failed");
|
||||||
|
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, timeout_ms = BROWSER_SMOKE_TIMEOUT.as_millis(), "browser WebTransport smoke timed out");
|
||||||
|
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: browser did not complete the smoke before the launcher deadline");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn main() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn prepare_listener() -> Result<(game_realtime_webtransport_lib::WebTransportListener, String, String), String> {
|
||||||
|
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(format!("loopback identity generation failed: {error}")),
|
||||||
|
};
|
||||||
|
let certificate_hash_hex = encode_hex(identity.certificate_hash().as_bytes());
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
|
||||||
|
let listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(format!("listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let endpoint = format!("https://{}/browser-smoke", listener.local_addr());
|
||||||
|
return Ok((listener, endpoint, certificate_hash_hex));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
async fn serve_browser(listener: &mut game_realtime_webtransport_lib::WebTransportListener) -> Result<(), String> {
|
||||||
|
let session = match listener.accept().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(format!("server accept failed: {error}")),
|
||||||
|
};
|
||||||
|
let connection = match session.accept_primary_connection().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(format!("server primary stream accept failed: {error}")),
|
||||||
|
};
|
||||||
|
let (mut sender, mut receiver) = connection.split();
|
||||||
|
let browser_received = match receiver.receive().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(format!("server receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(browser_received, shared::BROWSER_PAYLOAD) {
|
||||||
|
return Err(String::from("server did not receive the expected browser payload"));
|
||||||
|
}
|
||||||
|
if let Err(error) = sender.send(game_realtime_transport_lib::TransportMessage::new(shared::SERVER_PAYLOAD.to_vec())).await {
|
||||||
|
return Err(format!("server send failed: {error}"));
|
||||||
|
}
|
||||||
|
let browser_close = match receiver.receive().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(format!("server close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if browser_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return Err(String::from("server did not observe the browser FIN"));
|
||||||
|
}
|
||||||
|
if let Err(error) = sender.close().await {
|
||||||
|
return Err(format!("server close failed: {error}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn encode_hex(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut encoded = String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||||
|
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||||
|
return match receive {
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/shared.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Binary payload sent from the real browser to the native smoke peer.
|
||||||
|
pub(crate) const BROWSER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-browser-client-smoke";
|
||||||
|
/// Binary payload sent from the native smoke peer back to the real browser.
|
||||||
|
pub(crate) const SERVER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-browser-server-smoke";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# file: crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-webtransport-datagram-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||||
|
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// file: crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Executable localhost smoke for the backend-specific WebTransport datagram capability.
|
||||||
|
|
||||||
|
const CLIENT_DATAGRAM: &[u8] = b"games.sasedev-webtransport-client-datagram";
|
||||||
|
const DATAGRAM_RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
const SERVER_DATAGRAM: &[u8] = b"games.sasedev-webtransport-server-datagram";
|
||||||
|
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::webtransport::datagram-smoke";
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> std::process::ExitCode {
|
||||||
|
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||||
|
std::result::Result::Ok(guard) => guard,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("failed to initialize WebTransport datagram smoke tracing: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, "WebTransport datagram smoke started");
|
||||||
|
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(())) => {
|
||||||
|
tracing::info!(target: TRACING_TARGET, "WebTransport datagram smoke passed");
|
||||||
|
println!("game-realtime-webtransport-datagram-smoke: PASS");
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "WebTransport datagram smoke failed");
|
||||||
|
eprintln!("game-realtime-webtransport-datagram-smoke: FAIL: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "WebTransport datagram smoke timed out");
|
||||||
|
eprintln!("game-realtime-webtransport-datagram-smoke: FAIL: smoke timed out");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_smoke() -> std::result::Result<(), String> {
|
||||||
|
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("loopback identity generation failed: {error}")),
|
||||||
|
};
|
||||||
|
let certificate_hash = identity.certificate_hash().clone();
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
|
||||||
|
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let endpoint = format!("https://{}/datagram-smoke", listener.local_addr());
|
||||||
|
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client configuration failed: {error}")),
|
||||||
|
};
|
||||||
|
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
|
||||||
|
let server_session = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server accept failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_session = match client_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client connect failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_max = client_session.max_datagram_size();
|
||||||
|
let server_max = server_session.max_datagram_size();
|
||||||
|
if CLIENT_DATAGRAM.len() > client_max || SERVER_DATAGRAM.len() > server_max {
|
||||||
|
return std::result::Result::Err(format!("negotiated datagram capacity too small: client_max={client_max}, server_max={server_max}"));
|
||||||
|
}
|
||||||
|
tracing::info!(target: TRACING_TARGET, client_max, server_max, "WebTransport datagram capacities negotiated");
|
||||||
|
if let std::result::Result::Err(error) = client_session.send_datagram(CLIENT_DATAGRAM) {
|
||||||
|
return std::result::Result::Err(format!("client datagram send failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_received = match tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, server_session.receive_datagram()).await {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => return std::result::Result::Err(format!("server datagram receive failed: {error}")),
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(String::from("server datagram receive timed out; datagram delivery is not guaranteed")),
|
||||||
|
};
|
||||||
|
if server_received.as_slice() != CLIENT_DATAGRAM {
|
||||||
|
return std::result::Result::Err(String::from("server received an unexpected datagram payload"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_session.send_datagram(SERVER_DATAGRAM) {
|
||||||
|
return std::result::Result::Err(format!("server datagram send failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_received = match tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, client_session.receive_datagram()).await {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => return std::result::Result::Err(format!("client datagram receive failed: {error}")),
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(String::from("client datagram receive timed out; datagram delivery is not guaranteed")),
|
||||||
|
};
|
||||||
|
if client_received.as_slice() != SERVER_DATAGRAM {
|
||||||
|
return std::result::Result::Err(String::from("client received an unexpected datagram payload"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
21
crates/apps/game-realtime-webtransport-smoke/Cargo.toml
Normal file
21
crates/apps/game-realtime-webtransport-smoke/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# file: crates/apps/game-realtime-webtransport-smoke/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-webtransport-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||||
|
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||||
|
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
134
crates/apps/game-realtime-webtransport-smoke/src/main.rs
Normal file
134
crates/apps/game-realtime-webtransport-smoke/src/main.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// file: crates/apps/game-realtime-webtransport-smoke/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Executable localhost smoke for the public games.sasedev native WebTransport realtime path.
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const CLIENT_PAYLOAD: &[u8] = b"games.sasedev-webtransport-client-smoke";
|
||||||
|
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-server-smoke";
|
||||||
|
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::webtransport::smoke";
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> std::process::ExitCode {
|
||||||
|
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||||
|
std::result::Result::Ok(guard) => guard,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("failed to initialize realtime WebTransport smoke tracing: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, "realtime WebTransport smoke started");
|
||||||
|
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(())) => {
|
||||||
|
tracing::info!(target: TRACING_TARGET, "realtime WebTransport smoke passed");
|
||||||
|
println!("game-realtime-webtransport-smoke: PASS");
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "realtime WebTransport smoke failed");
|
||||||
|
eprintln!("game-realtime-webtransport-smoke: FAIL: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime WebTransport smoke timed out");
|
||||||
|
eprintln!("game-realtime-webtransport-smoke: FAIL: smoke timed out");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_smoke() -> std::result::Result<(), String> {
|
||||||
|
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("loopback identity generation failed: {error}")),
|
||||||
|
};
|
||||||
|
let certificate_hash = identity.certificate_hash().clone();
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
|
||||||
|
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("listener bind failed: {error}")),
|
||||||
|
};
|
||||||
|
let endpoint = format!("https://{}/smoke", listener.local_addr());
|
||||||
|
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client configuration failed: {error}")),
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "loopback endpoint bound with exact certificate pin");
|
||||||
|
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
|
||||||
|
let server_session = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server accept failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_session = match client_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client connect failed: {error}")),
|
||||||
|
};
|
||||||
|
let client_connection = match client_session.open_primary_connection().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client primary stream open failed: {error}")),
|
||||||
|
};
|
||||||
|
let server_connection = match server_session.accept_primary_connection().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server primary stream accept failed: {error}")),
|
||||||
|
};
|
||||||
|
let (mut server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
let (mut client_sender, mut client_receiver) = client_connection.split();
|
||||||
|
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec())).await {
|
||||||
|
return std::result::Result::Err(format!("client send failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_received = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(server_received, CLIENT_PAYLOAD) {
|
||||||
|
return std::result::Result::Err(String::from("server did not receive the expected client payload"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec())).await {
|
||||||
|
return std::result::Result::Err(format!("server send failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_received = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client receive failed: {error}")),
|
||||||
|
};
|
||||||
|
if !receive_matches(client_received, SERVER_PAYLOAD) {
|
||||||
|
return std::result::Result::Err(String::from("client did not receive the expected server payload"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = client_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("client close failed: {error}"));
|
||||||
|
}
|
||||||
|
let server_close = match server_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("server close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("server did not observe the client FIN"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = server_sender.close().await {
|
||||||
|
return std::result::Result::Err(format!("server close failed: {error}"));
|
||||||
|
}
|
||||||
|
let client_close = match client_receiver.receive().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(format!("client close observation failed: {error}")),
|
||||||
|
};
|
||||||
|
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||||
|
return std::result::Result::Err(String::from("client did not observe the server FIN"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||||
|
return match receive {
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Reflex POC Tauri",
|
"productName": "Reflex POC Tauri",
|
||||||
"version": "0.1.0",
|
|
||||||
"identifier": "com.sasedev.games.reflex.tauri",
|
"identifier": "com.sasedev.games.reflex.tauri",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": {
|
"beforeDevCommand": {
|
||||||
@@ -31,6 +30,8 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": false,
|
"active": false,
|
||||||
"icon": ["icons/icon.png"]
|
"icon": [
|
||||||
|
"icons/icon.png"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
crates/apps/game-snake-poc-tauri/Cargo.toml
Normal file
34
crates/apps/game-snake-poc-tauri/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# file: crates/apps/game-snake-poc-tauri/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-snake-poc-tauri"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "game_snake_poc_tauri_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "game-snake-poc-tauri"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
engine-v1-platform-api = { path = "../../engines/engine-v1-platform-api" }
|
||||||
|
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||||
|
serde.workspace = true
|
||||||
|
tauri.workspace = true
|
||||||
|
tauri-plugin-tracing.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
45
crates/apps/game-snake-poc-tauri/README.md
Normal file
45
crates/apps/game-snake-poc-tauri/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!-- file: crates/apps/game-snake-poc-tauri/README.md -->
|
||||||
|
<!-- version: 9 -->
|
||||||
|
|
||||||
|
# game-snake-poc-tauri
|
||||||
|
|
||||||
|
Host Tauri Android de référence du POC Snake. Cette application réutilise le gameplay Rust de `game-snake-poc` et l'unique adapter WebAssembly `game-snake-poc-wasm` ; elle ne possède aucune règle de jeu.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
La crate possède uniquement la frontière Tauri : initialisation du logging Rust, assemblage/plugins/commandes Tauri, contrat minimal de provenance native et frontend Vite/TypeScript exécuté dans la WebView Android.
|
||||||
|
|
||||||
|
L'organisation Rust suit le modèle des applications Desk KSP :
|
||||||
|
|
||||||
|
- `src/lib.rs` reste une façade et réexporte les éléments partagés au crate-root ;
|
||||||
|
- `src/tauri.rs` assemble Tauri et fait déléguer les commandes aux modules propriétaires ;
|
||||||
|
- `src/runtime.rs` possède le petit contrat runtime/provenance ;
|
||||||
|
- `frontend/` contient le host WebView et consomme les bindings générés de `game-snake-poc-wasm`.
|
||||||
|
|
||||||
|
À partir de `0.3.1-0-pre.3`, le frontend fournit aussi le Canvas, la boucle fixed-step, les contrôles clavier/touch et le chargement des assets canoniques. `0-pre.4` ajoute la suspension/reprise explicite, le suivi de visibilité et le resize/orientation.
|
||||||
|
|
||||||
|
## Statut du POC
|
||||||
|
|
||||||
|
Ce host est conservé comme **POC de référence** et non comme template de production Android. La voie de jeu Android privilégiée reste SDL3 natif/Java/JNI, qui couvre déjà les besoins principaux avec une chaîne de build plus directe.
|
||||||
|
|
||||||
|
Tauri Android reste utile pour comparer une WebView/WASM à la voie native et pour tester ultérieurement des besoins spécifiques, par exemple la saisie texte. Une future distribution Tauri Desktop est une décision séparée, conditionnée à un bénéfice produit concret tel qu’une monétisation WebView exploitable.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Les hooks `beforeDevCommand` et `beforeBuildCommand` de Tauri possèdent le build WASM et Vite. Aucun orchestrateur Python n'est utilisé. Les bindings WASM, la distribution Vite, les caches, `node_modules/` et le projet Android généré restent hors des sources livrées ou dans des chemins générés ignorés.
|
||||||
|
|
||||||
|
Le host ne modifie pas le projet Android généré par Tauri. Pour `0.3.1`, la chaîne Tauri 2.11.x est conservée telle que générée : Gradle `8.14.3` et Android Gradle Plugin `8.11.0`. Le JDK Android de référence du projet est JDK 17, qui correspond au minimum et à la valeur par défaut documentés par AGP 8.11. Le Java 25 global et le JBR 25 de l'Android Studio actuellement installé ne doivent pas exécuter ce wrapper.
|
||||||
|
|
||||||
|
Voir [`USAGE.md`](USAGE.md) pour l'initialisation locale et les smokes Android.
|
||||||
|
|
||||||
|
## Lifecycle et Back Android
|
||||||
|
|
||||||
|
Le host suspend explicitement sa boucle `requestAnimationFrame` lorsque la WebView devient cachée et la reprend en réinitialisant son horloge locale. Une reprise après background ne doit donc pas simuler le temps passé hors écran. Resize et rotation passent par un `ResizeObserver` sur la zone de jeu et réévaluent aussi la provenance device.
|
||||||
|
|
||||||
|
Le POC reste mono-page et n’enregistre volontairement aucun handler Tauri `onBackButtonPress`. La politique `EngineGame::quit_requested` de Snake est actuellement la politique moteur par défaut `Exit`, cohérente avec le Back Android/Tauri naturel. Un futur jeu retournant `Continue` devra introduire une intégration Back explicite au lieu de modifier ce POC silencieusement.
|
||||||
|
|
||||||
|
Le premier smoke ARM64 réel a mis en évidence un abort natif pendant un teardown déclenché par Back : `FORTIFY: pthread_mutex_lock called on a destroyed mutex`. Après correction du tunnel HMR afin que HTTP et WebSocket partagent tous deux `1436`, le smoke `0-pre.4.fix.1` ne l'a pas reproduit. La beta a ensuite validé la construction d'un APK autonome universal et son installation/lancement sur ARM64 réel et AVD x86_64. La RC conserve le teardown/Back dans ses smokes finaux sans ajouter de handler ad hoc ni patcher le scaffold généré.
|
||||||
|
|
||||||
|
## Limite ergonomique connue
|
||||||
|
|
||||||
|
Le D-pad tactile reste sous le Canvas dans ce POC et peut nécessiter un scroll sur petit écran. Des contrôles superposés ou des gestes swipe seraient techniquement possibles, mais ne sont pas introduits dans `0.3.1` : ils ne changent pas la conclusion d’architecture et appartiendraient à une UX produit réelle.
|
||||||
119
crates/apps/game-snake-poc-tauri/USAGE.md
Normal file
119
crates/apps/game-snake-poc-tauri/USAGE.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!-- file: crates/apps/game-snake-poc-tauri/USAGE.md -->
|
||||||
|
<!-- version: 9 -->
|
||||||
|
|
||||||
|
# Utilisation — Snake Tauri Android
|
||||||
|
|
||||||
|
## Prérequis locaux
|
||||||
|
|
||||||
|
Le host utilise Cargo/Rust, `cargo tauri`, Node/npm, `wasm-bindgen`, Java et le SDK Android. Le dépôt attend `ANDROID_HOME` vers le SDK Android et des NDK side-by-side sous `$ANDROID_HOME/ndk/<version>`.
|
||||||
|
|
||||||
|
`NDK_HOME` n'est pas un prérequis utilisateur du projet. Tauri sélectionne la version NDK side-by-side appropriée sous `$ANDROID_HOME/ndk/<version>` et peut injecter lui-même `NDK_HOME` dans ses sous-processus Android. Une variable globale ne doit donc pas être ajoutée uniquement pour satisfaire la documentation générique de Tauri.
|
||||||
|
|
||||||
|
Pour `0.3.1`, le JDK Android de référence est **JDK 17**. Le template Tauri 2.11.x utilisé par le projet génère Gradle `8.14.3` avec Android Gradle Plugin `8.11.0` : AGP 8.11 exige au minimum JDK 17 et Gradle 8.14.x ne peut pas s'exécuter sur Java 25. JDK 17 fournit donc la baseline reproductible commune aux deux contraintes.
|
||||||
|
|
||||||
|
Le Java 25 global de la machine de validation peut rester installé. En revanche, le JBR de l'Android Studio actuellement installé est lui aussi Java 25 et ne doit donc pas être utilisé pour cette gate malgré la recommandation générique de Tauri d'utiliser le JBR d'Android Studio. Le Gradle global installé sur la machine n'est pas utilisé par `gen/android/gradlew` et n'est pas un prérequis du projet.
|
||||||
|
|
||||||
|
Avant une commande Tauri Android, sélectionner explicitement un JDK 17 installé et vérifier la version :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/chemin/vers/jdk-17
|
||||||
|
"$JAVA_HOME/bin/java" -version
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas modifier `gen/android` pour contourner une incompatibilité de JDK.
|
||||||
|
|
||||||
|
Pour les cibles actuellement disponibles, `aarch64-linux-android` permet le smoke sur l'appareil ARM64 et `x86_64-linux-android` celui des AVD x86_64. Les autres ABI restent disponibles pour les validations multi-ABI ultérieures mais ne sont pas nécessaires au smoke courant.
|
||||||
|
|
||||||
|
## Initialisation locale
|
||||||
|
|
||||||
|
Installer les dépendances frontend dans la crate :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/apps/game-snake-poc-tauri && npm install)
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialiser une fois le projet Android généré par Tauri :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/apps/game-snake-poc-tauri && cargo tauri android init)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le répertoire `gen/` produit par Tauri est généré et ignoré ; il n'appartient pas aux deltas et ne doit pas être patché par des scripts propres au jeu.
|
||||||
|
|
||||||
|
Si ce répertoire a été créé ou modifié par `0-pre.2.fix.1`, il est pollué par la tentative abandonnée de migration Gradle 9. Il faut alors le régénérer intégralement avec la CLI Tauri officielle, sous JDK 17 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf crates/apps/game-snake-poc-tauri/gen/android
|
||||||
|
(cd crates/apps/game-snake-poc-tauri && cargo tauri android init)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'audit de distribution vérifie désormais un `gen/android` présent et refuse un wrapper/AGP qui ne correspondent pas au template Tauri 2.11.x attendu.
|
||||||
|
|
||||||
|
## Smoke Android de développement
|
||||||
|
|
||||||
|
Lancer ensuite :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/apps/game-snake-poc-tauri && cargo tauri android dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Choisir l'appareil réel ou l'AVD voulu lorsque la CLI le demande. Le frontend doit afficher le Canvas Snake, les compteurs score/longueur, les contrôles directionnels et les assets `snake / engine-v1`. La provenance doit évoluer depuis `android / tauri-webview / wasm / <device> / unknown` vers un profil d'entrée observé (`touch`, `keyboard-mouse` ou `mixed`) après utilisation des contrôles. Les événements de démarrage doivent apparaître dans le tracing/logcat.
|
||||||
|
|
||||||
|
Ne pas utiliser `npm run dev` ou `npm run build` comme gate manuelle : ces scripts sont des hooks internes possédés par Tauri. `cargo tauri android build` reste réservé au jalon de packaging prévu par le plan, sauf diagnostic explicite.
|
||||||
|
|
||||||
|
### Appareil Android réel via USB
|
||||||
|
|
||||||
|
Sur un appareil physique, Tauri remplace automatiquement le host `localhost` du `devUrl` par une adresse réseau. Pour la gate de référence, le chemin reproductible retenu évite le LAN et utilise le tunnel USB ADB sur le même port HTTP/WebSocket Vite :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <serial> reverse --remove-all
|
||||||
|
adb -s <serial> reverse tcp:1436 tcp:1436
|
||||||
|
adb -s <serial> reverse --list
|
||||||
|
|
||||||
|
(cd crates/apps/game-snake-poc-tauri && cargo tauri android dev --host 127.0.0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `serial` vient de `adb devices -l` et ne doit jamais être enregistré dans les sources. La configuration Vite utilise le port `1436` pour HTTP et WebSocket afin que le même tunnel transporte aussi le HMR. Un port HMR séparé n'appartient pas au contrat de ce POC.
|
||||||
|
|
||||||
|
Pour vérifier l'ABI réelle avant le smoke :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <serial> shell getprop ro.product.cpu.abi
|
||||||
|
adb -s <serial> shell getprop ro.product.cpu.abilist
|
||||||
|
```
|
||||||
|
|
||||||
|
`arm64-v8a` correspond à la cible Rust `aarch64-linux-android`, tandis que les AVD utilisés jusqu'ici sont `x86_64-linux-android`.
|
||||||
|
|
||||||
|
## Positionnement
|
||||||
|
|
||||||
|
`game-snake-poc-tauri` est un POC de référence. Il ne doit pas être utilisé comme base automatique des futurs jeux Android ; la cible Android productive reste SDL3 natif tant qu’un besoin concret ne justifie pas la WebView Tauri.
|
||||||
|
|
||||||
|
## Smoke lifecycle `0-pre.4`
|
||||||
|
|
||||||
|
Pendant `cargo tauri android dev` :
|
||||||
|
|
||||||
|
1. lancer une partie et effectuer au moins une entrée tactile ;
|
||||||
|
2. mettre l’application en arrière-plan puis la ramener au premier plan ;
|
||||||
|
3. vérifier que le serpent reprend sans accélération ni rattrapage de la durée passée en background ;
|
||||||
|
4. faire pivoter/redimensionner l’émulateur si disponible et vérifier que le Canvas reste rendu ;
|
||||||
|
5. utiliser Back Android : pour Snake, l’application doit suivre le comportement système de sortie, sans navigation Web interne ni interception spécifique ;
|
||||||
|
6. vérifier dans logcat les traces `runtime paused`, `runtime resumed` et les événements `visibility hidden/visible`.
|
||||||
|
|
||||||
|
Le D-pad placé sous le Canvas est une limite ergonomique connue du POC de référence et n’est pas un critère bloquant de cette gate.
|
||||||
|
|
||||||
|
Sur le Galaxy S9+ ARM64 utilisé pour la validation réelle, chargement, WASM, Canvas, assets et touch fonctionnent via le tunnel USB. Un premier smoke a observé un abort natif `FORTIFY: pthread_mutex_lock called on a destroyed mutex` pendant Back/teardown ; après passage de HTTP et HMR sur le même tunnel `1436`, le smoke `0-pre.4.fix.1` ne l'a pas reproduit. La beta a ensuite construit un APK autonome universal et l'a installé/lancé sur le Galaxy S9+ ARM64 ainsi que sur l'AVD x86_64. La RC revalide ces chemins sans modifier `gen/android`.
|
||||||
|
|
||||||
|
## Packaging beta Android
|
||||||
|
|
||||||
|
À partir de `0.3.1-2-beta.1`, le packaging de validation utilise un APK Debug autonome contenant les deux ABI réellement testées par ce POC : `arm64-v8a` pour l'appareil physique et `x86_64` pour l'AVD. Le build reste possédé par Tauri ; son hook `beforeBuildCommand` construit le WASM release, génère les bindings, exécute TypeScript/Vite et copie les assets.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/temurin-17-jdk-amd64
|
||||||
|
(cd crates/apps/game-snake-poc-tauri && \
|
||||||
|
cargo tauri android build --debug --apk --target aarch64 --target x86_64)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas lancer `npm run build` séparément pour cette gate. Tauri affiche le chemin de l'APK produit ; les outputs sous `gen/android/**/build/` sont générés et ignorés. Vérifier ensuite que l'APK contient les bibliothèques `arm64-v8a` et `x86_64`, puis installer ce même APK successivement sur l'AVD et l'appareil réel avec `adb install -r`.
|
||||||
|
|
||||||
|
Le package autonome ne doit dépendre ni de Vite, ni du port `1436`, ni d'un tunnel ADB. La beta a validé sa construction et son installation/lancement sur les deux architectures de référence. La RC reprend les contrôles fonctionnels lifecycle/Back sur l'état gelé. Aucun AAB Tauri n'est requis : le futur packaging AAB appartient à la voie Android SDL3 native.
|
||||||
|
|
||||||
9
crates/apps/game-snake-poc-tauri/build.rs
Normal file
9
crates/apps/game-snake-poc-tauri/build.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/build.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Build-time integration for the Snake Tauri host.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
return;
|
||||||
|
}
|
||||||
12
crates/apps/game-snake-poc-tauri/capabilities/default.json
Normal file
12
crates/apps/game-snake-poc-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the Snake Tauri Android POC",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"tracing:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
235
crates/apps/game-snake-poc-tauri/frontend/css/main.css
Normal file
235
crates/apps/game-snake-poc-tauri/frontend/css/main.css
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/* file: crates/apps/game-snake-poc-tauri/frontend/css/main.css */
|
||||||
|
/* version: 2 */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
background: #101418;
|
||||||
|
color: #f4f7f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
|
||||||
|
max(0.75rem, env(safe-area-inset-left));
|
||||||
|
background: #101418;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header,
|
||||||
|
.app-footer,
|
||||||
|
.game-panel,
|
||||||
|
.control-panel {
|
||||||
|
border: 1px solid #50606b;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #161c21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
|
color: #9fb0ba;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: clamp(1.25rem, 6vw, 1.85rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge,
|
||||||
|
.game-summary output {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #29343c;
|
||||||
|
color: #f4f7f8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-runtime-state="ready"] .status-badge {
|
||||||
|
background: #1d5f3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-runtime-state="error"] .status-badge {
|
||||||
|
background: #7a2630;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-block: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-panel,
|
||||||
|
.control-panel {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-stage {
|
||||||
|
width: min(100%, 21rem);
|
||||||
|
max-height: 58vh;
|
||||||
|
margin-inline: auto;
|
||||||
|
aspect-ratio: 3 / 5;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #6d7f8a;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 3px #8fbfff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel > div:first-child p {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #b9c4ca;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(3.75rem, 5rem));
|
||||||
|
grid-template-rows: repeat(2, minmax(3.3rem, 4.2rem));
|
||||||
|
gap: 0.45rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-inline: auto;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad button {
|
||||||
|
border: 1px solid #7892a1;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: #202b32;
|
||||||
|
color: #f4f7f8;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad button:active {
|
||||||
|
background: #355365;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-up {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-left {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-down {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-right {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-details {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
border-top: 1px solid #3a474f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-details div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-details dt {
|
||||||
|
color: #a9b6bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-details dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startup-error {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid #b34b57;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #51232a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
color: #a9b6bd;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 760px) {
|
||||||
|
.app-main {
|
||||||
|
grid-template-columns: minmax(18rem, 1fr) minmax(17rem, 22rem);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-stage {
|
||||||
|
width: min(100%, 24rem);
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/apps/game-snake-poc-tauri/frontend/main.html
Normal file
59
crates/apps/game-snake-poc-tauri/frontend/main.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!-- file: crates/apps/game-snake-poc-tauri/frontend/main.html -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#101418" />
|
||||||
|
<title>Snake POC — Tauri Android</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">games.sasedev</p>
|
||||||
|
<h1>Snake — Tauri Android</h1>
|
||||||
|
</div>
|
||||||
|
<span id="runtime-status" class="status-badge" role="status" aria-live="polite">Initialisation…</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<section class="game-panel" aria-label="Partie Snake">
|
||||||
|
<div class="game-summary">
|
||||||
|
<output id="score">Score : 0</output>
|
||||||
|
<output id="length">Longueur : 0</output>
|
||||||
|
</div>
|
||||||
|
<div id="game-stage" class="game-stage">
|
||||||
|
<canvas id="game" width="360" height="600" tabindex="0" aria-label="Zone de jeu Snake"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="control-panel" aria-label="Contrôles et runtime">
|
||||||
|
<div>
|
||||||
|
<h2>Contrôles</h2>
|
||||||
|
<p>Flèches, WASD/ZQSD ou boutons tactiles.</p>
|
||||||
|
</div>
|
||||||
|
<div class="direction-pad" aria-label="Contrôles directionnels tactiles">
|
||||||
|
<button class="direction-up" type="button" data-direction="up" aria-label="Haut">↑</button>
|
||||||
|
<button class="direction-left" type="button" data-direction="left" aria-label="Gauche">←</button>
|
||||||
|
<button class="direction-down" type="button" data-direction="down" aria-label="Bas">↓</button>
|
||||||
|
<button class="direction-right" type="button" data-direction="right" aria-label="Droite">→</button>
|
||||||
|
</div>
|
||||||
|
<dl class="runtime-details">
|
||||||
|
<div>
|
||||||
|
<dt>Provenance</dt>
|
||||||
|
<dd><output id="runtime-provenance">android / tauri-webview / wasm / unknown / unknown</output></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Assets</dt>
|
||||||
|
<dd><output id="asset-status">chargement…</output></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p id="startup-error" class="startup-error" role="alert" hidden></p>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">POC de référence Tauri Android — gameplay Rust partagé</footer>
|
||||||
|
<script type="module" src="/ts/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
crates/apps/game-snake-poc-tauri/frontend/ts/assets.ts
Normal file
54
crates/apps/game-snake-poc-tauri/frontend/ts/assets.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/assets.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
export interface SnakeTauriAssets {
|
||||||
|
engineGeneration: number;
|
||||||
|
game: string;
|
||||||
|
gameKind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommonRuntimeAsset {
|
||||||
|
schema: number;
|
||||||
|
scope: string;
|
||||||
|
engine_generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameRuntimeAsset {
|
||||||
|
schema: number;
|
||||||
|
game: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(relativePath: string): Promise<T> {
|
||||||
|
const response = await fetch(new URL(relativePath, document.baseURI));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Asset runtime indisponible (${response.status}) : ${relativePath}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommonRuntime(asset: CommonRuntimeAsset): void {
|
||||||
|
if (asset.schema !== 1 || asset.scope !== "common" || asset.engine_generation !== 1) {
|
||||||
|
throw new Error("Asset common://data/runtime.json invalide pour le POC Tauri Snake.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGameRuntime(asset: GameRuntimeAsset): void {
|
||||||
|
if (asset.schema !== 1 || asset.game !== "game-snake-poc" || asset.kind !== "snake") {
|
||||||
|
throw new Error("Asset game://data/game.json invalide pour le POC Tauri Snake.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSnakeTauriAssets(): Promise<SnakeTauriAssets> {
|
||||||
|
const [commonRuntime, gameRuntime] = await Promise.all([
|
||||||
|
fetchJson<CommonRuntimeAsset>("./common/data/runtime.json"),
|
||||||
|
fetchJson<GameRuntimeAsset>("./game/data/game.json"),
|
||||||
|
]);
|
||||||
|
validateCommonRuntime(commonRuntime);
|
||||||
|
validateGameRuntime(gameRuntime);
|
||||||
|
return {
|
||||||
|
engineGeneration: commonRuntime.engine_generation,
|
||||||
|
game: gameRuntime.game,
|
||||||
|
gameKind: gameRuntime.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
crates/apps/game-snake-poc-tauri/frontend/ts/bridge.ts
Normal file
20
crates/apps/game-snake-poc-tauri/frontend/ts/bridge.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/bridge.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export interface RuntimeDescriptor {
|
||||||
|
deviceClass: string;
|
||||||
|
executionModel: string;
|
||||||
|
inputProfile: string;
|
||||||
|
platformFamily: string;
|
||||||
|
runtimeHost: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRuntimeDescriptor(): Promise<RuntimeDescriptor> {
|
||||||
|
return await invoke<RuntimeDescriptor>("get_runtime_descriptor");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportFrontendReady(): Promise<void> {
|
||||||
|
await invoke("frontend_ready");
|
||||||
|
}
|
||||||
182
crates/apps/game-snake-poc-tauri/frontend/ts/game.ts
Normal file
182
crates/apps/game-snake-poc-tauri/frontend/ts/game.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/game.ts
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
import init, { SnakeWasmGame } from "@snake-wasm";
|
||||||
|
import type { RuntimeDescriptor } from "./bridge";
|
||||||
|
import { frontendInfo } from "./logging";
|
||||||
|
import { TauriRuntimeProvenance, type SnakeInputSource } from "./provenance";
|
||||||
|
|
||||||
|
const FRAME_MILLIS = 16;
|
||||||
|
const MAX_FRAME_ELAPSED_MILLIS = 250;
|
||||||
|
|
||||||
|
export type SnakeDirection = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
export interface SnakeTauriSession {
|
||||||
|
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void;
|
||||||
|
pause(): void;
|
||||||
|
resume(): void;
|
||||||
|
renderNow(): void;
|
||||||
|
refreshDeviceClass(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function color(red: number, green: number, blue: number): string {
|
||||||
|
return `rgb(${red} ${green} ${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas(canvas: HTMLCanvasElement): void {
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));
|
||||||
|
const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(
|
||||||
|
game: SnakeWasmGame,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
score: HTMLOutputElement,
|
||||||
|
length: HTMLOutputElement,
|
||||||
|
): void {
|
||||||
|
resizeCanvas(canvas);
|
||||||
|
context.fillStyle = color(game.background_red(), game.background_green(), game.background_blue());
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const count = game.rectangle_count();
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
context.fillStyle = color(game.rectangle_red(index), game.rectangle_green(index), game.rectangle_blue(index));
|
||||||
|
context.fillRect(
|
||||||
|
game.rectangle_x(index) * canvas.width,
|
||||||
|
game.rectangle_y(index) * canvas.height,
|
||||||
|
game.rectangle_width(index) * canvas.width,
|
||||||
|
game.rectangle_height(index) * canvas.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
score.value = `Score : ${game.score()}`;
|
||||||
|
length.value = `Longueur : ${game.length()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueDirection(game: SnakeWasmGame, direction: SnakeDirection): void {
|
||||||
|
switch (direction) {
|
||||||
|
case "left":
|
||||||
|
game.left();
|
||||||
|
return;
|
||||||
|
case "right":
|
||||||
|
game.right();
|
||||||
|
return;
|
||||||
|
case "up":
|
||||||
|
game.up();
|
||||||
|
return;
|
||||||
|
case "down":
|
||||||
|
game.down();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startGame(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
score: HTMLOutputElement,
|
||||||
|
length: HTMLOutputElement,
|
||||||
|
provenanceOutput: HTMLOutputElement,
|
||||||
|
descriptor: RuntimeDescriptor,
|
||||||
|
): Promise<SnakeTauriSession> {
|
||||||
|
await init();
|
||||||
|
frontendInfo("Snake Tauri WASM initialized");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("Le contexte Canvas 2D est indisponible.");
|
||||||
|
}
|
||||||
|
const renderingContext: CanvasRenderingContext2D = context;
|
||||||
|
const game = new SnakeWasmGame();
|
||||||
|
const provenance = new TauriRuntimeProvenance(game, provenanceOutput, descriptor);
|
||||||
|
let previous = performance.now();
|
||||||
|
let accumulator = 0;
|
||||||
|
let animationFrame: number | null = null;
|
||||||
|
let running = false;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function renderNow(): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render(game, canvas, renderingContext, score, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(): void {
|
||||||
|
if (!running || disposed || animationFrame !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animationFrame = window.requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now: number): void {
|
||||||
|
animationFrame = null;
|
||||||
|
if (!running || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = Math.min(Math.max(0, now - previous), MAX_FRAME_ELAPSED_MILLIS);
|
||||||
|
previous = now;
|
||||||
|
accumulator += elapsed;
|
||||||
|
while (accumulator >= FRAME_MILLIS) {
|
||||||
|
game.tick();
|
||||||
|
accumulator -= FRAME_MILLIS;
|
||||||
|
}
|
||||||
|
renderNow();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(): void {
|
||||||
|
if (!running || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = false;
|
||||||
|
accumulator = 0;
|
||||||
|
if (animationFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = null;
|
||||||
|
}
|
||||||
|
frontendInfo("Snake Tauri runtime paused");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume(): void {
|
||||||
|
if (running || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previous = performance.now();
|
||||||
|
accumulator = 0;
|
||||||
|
running = true;
|
||||||
|
schedule();
|
||||||
|
frontendInfo("Snake Tauri runtime resumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNow();
|
||||||
|
resume();
|
||||||
|
return {
|
||||||
|
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
provenance.recordInput(source);
|
||||||
|
queueDirection(game, direction);
|
||||||
|
},
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
renderNow,
|
||||||
|
refreshDeviceClass(): void {
|
||||||
|
if (!disposed) {
|
||||||
|
provenance.refreshDeviceClass();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose(): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pause();
|
||||||
|
disposed = true;
|
||||||
|
game.free();
|
||||||
|
frontendInfo("Snake Tauri runtime disposed");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
57
crates/apps/game-snake-poc-tauri/frontend/ts/input.ts
Normal file
57
crates/apps/game-snake-poc-tauri/frontend/ts/input.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/input.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import type { SnakeDirection, SnakeTauriSession } from "./game";
|
||||||
|
|
||||||
|
const KEY_DIRECTIONS: Readonly<Record<string, SnakeDirection>> = {
|
||||||
|
arrowleft: "left",
|
||||||
|
a: "left",
|
||||||
|
q: "left",
|
||||||
|
arrowright: "right",
|
||||||
|
d: "right",
|
||||||
|
arrowup: "up",
|
||||||
|
w: "up",
|
||||||
|
z: "up",
|
||||||
|
arrowdown: "down",
|
||||||
|
s: "down",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buttonDirection(button: HTMLButtonElement): SnakeDirection | null {
|
||||||
|
const direction = button.dataset.direction;
|
||||||
|
if (direction === "left" || direction === "right" || direction === "up" || direction === "down") {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindInputs(session: SnakeTauriSession, buttons: readonly HTMLButtonElement[]): void {
|
||||||
|
window.addEventListener("keydown", event => {
|
||||||
|
if (event.repeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const direction = KEY_DIRECTIONS[event.key.toLowerCase()];
|
||||||
|
if (direction === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
session.queueDirection(direction, "keyboard-mouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const button of buttons) {
|
||||||
|
const direction = buttonDirection(button);
|
||||||
|
if (direction === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
button.addEventListener("pointerdown", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const source = event.pointerType === "touch" ? "touch" : "keyboard-mouse";
|
||||||
|
session.queueDirection(direction, source);
|
||||||
|
});
|
||||||
|
button.addEventListener("click", event => {
|
||||||
|
if (event.detail !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.queueDirection(direction, "keyboard-mouse");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/apps/game-snake-poc-tauri/frontend/ts/lifecycle.ts
Normal file
60
crates/apps/game-snake-poc-tauri/frontend/ts/lifecycle.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/lifecycle.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import type { SnakeTauriSession } from "./game";
|
||||||
|
import { frontendInfo } from "./logging";
|
||||||
|
|
||||||
|
export interface SnakeTauriLifecycleBinding {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindTauriLifecycle(session: SnakeTauriSession, stage: HTMLElement): SnakeTauriLifecycleBinding {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
session.refreshDeviceClass();
|
||||||
|
session.renderNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function synchronizeVisibility(): void {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
session.pause();
|
||||||
|
frontendInfo("Snake Tauri lifecycle: visibility hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.resume();
|
||||||
|
session.refreshDeviceClass();
|
||||||
|
session.renderNow();
|
||||||
|
frontendInfo("Snake Tauri lifecycle: visibility visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHide(event: PageTransitionEvent): void {
|
||||||
|
if (event.persisted) {
|
||||||
|
session.pause();
|
||||||
|
} else {
|
||||||
|
session.dispose();
|
||||||
|
}
|
||||||
|
frontendInfo(`Snake Tauri lifecycle: page hide persisted=${event.persisted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageShow(event: PageTransitionEvent): void {
|
||||||
|
session.resume();
|
||||||
|
session.refreshDeviceClass();
|
||||||
|
session.renderNow();
|
||||||
|
frontendInfo(`Snake Tauri lifecycle: page show persisted=${event.persisted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver.observe(stage);
|
||||||
|
document.addEventListener("visibilitychange", synchronizeVisibility);
|
||||||
|
window.addEventListener("pagehide", pageHide);
|
||||||
|
window.addEventListener("pageshow", pageShow);
|
||||||
|
synchronizeVisibility();
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispose(): void {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", synchronizeVisibility);
|
||||||
|
window.removeEventListener("pagehide", pageHide);
|
||||||
|
window.removeEventListener("pageshow", pageShow);
|
||||||
|
session.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
crates/apps/game-snake-poc-tauri/frontend/ts/logging.ts
Normal file
16
crates/apps/game-snake-poc-tauri/frontend/ts/logging.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/logging.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { attachConsole, error, info } from "@fltsci/tauri-plugin-tracing";
|
||||||
|
|
||||||
|
export async function initializeFrontendTracing(): Promise<void> {
|
||||||
|
await attachConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendInfo(message: string): void {
|
||||||
|
info(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendError(message: string): void {
|
||||||
|
error(message);
|
||||||
|
}
|
||||||
65
crates/apps/game-snake-poc-tauri/frontend/ts/main.ts
Normal file
65
crates/apps/game-snake-poc-tauri/frontend/ts/main.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/main.ts
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
import "../css/main.css";
|
||||||
|
import { loadSnakeTauriAssets } from "./assets";
|
||||||
|
import { getRuntimeDescriptor, reportFrontendReady } from "./bridge";
|
||||||
|
import { startGame } from "./game";
|
||||||
|
import { bindInputs } from "./input";
|
||||||
|
import { bindTauriLifecycle } from "./lifecycle";
|
||||||
|
import { frontendError, frontendInfo, initializeFrontendTracing } from "./logging";
|
||||||
|
|
||||||
|
function requiredElement<T extends HTMLElement>(identifier: string): T {
|
||||||
|
const element = document.getElementById(identifier);
|
||||||
|
if (element === null) {
|
||||||
|
throw new Error(`Élément frontend requis absent : ${identifier}`);
|
||||||
|
}
|
||||||
|
return element as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
await initializeFrontendTracing();
|
||||||
|
frontendInfo("Snake Tauri frontend bootstrap started");
|
||||||
|
const descriptor = await getRuntimeDescriptor();
|
||||||
|
const canvas = requiredElement<HTMLCanvasElement>("game");
|
||||||
|
const stage = requiredElement<HTMLElement>("game-stage");
|
||||||
|
const score = requiredElement<HTMLOutputElement>("score");
|
||||||
|
const length = requiredElement<HTMLOutputElement>("length");
|
||||||
|
const provenance = requiredElement<HTMLOutputElement>("runtime-provenance");
|
||||||
|
const assets = requiredElement<HTMLOutputElement>("asset-status");
|
||||||
|
const status = requiredElement<HTMLSpanElement>("runtime-status");
|
||||||
|
const error = requiredElement<HTMLParagraphElement>("startup-error");
|
||||||
|
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-direction]"));
|
||||||
|
|
||||||
|
status.textContent = "Chargement assets…";
|
||||||
|
const assetMetadata = await loadSnakeTauriAssets();
|
||||||
|
assets.value = `${assetMetadata.gameKind} / engine-v${assetMetadata.engineGeneration}`;
|
||||||
|
frontendInfo(`Snake Tauri assets ready: ${assetMetadata.game} / ${assets.value}`);
|
||||||
|
|
||||||
|
status.textContent = "Chargement WASM…";
|
||||||
|
const session = await startGame(canvas, score, length, provenance, descriptor);
|
||||||
|
bindInputs(session, buttons);
|
||||||
|
bindTauriLifecycle(session, stage);
|
||||||
|
|
||||||
|
document.body.dataset.runtimeState = "ready";
|
||||||
|
status.textContent = "Prêt";
|
||||||
|
error.hidden = true;
|
||||||
|
canvas.focus();
|
||||||
|
await reportFrontendReady();
|
||||||
|
frontendInfo(`Snake Tauri frontend ready: ${provenance.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap().catch(reason => {
|
||||||
|
const message = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
|
||||||
|
document.body.dataset.runtimeState = "error";
|
||||||
|
const status = document.getElementById("runtime-status");
|
||||||
|
const error = document.getElementById("startup-error");
|
||||||
|
if (status !== null) {
|
||||||
|
status.textContent = "Erreur";
|
||||||
|
}
|
||||||
|
if (error !== null) {
|
||||||
|
error.textContent = `Impossible de démarrer Snake : ${message}`;
|
||||||
|
error.hidden = false;
|
||||||
|
}
|
||||||
|
frontendError(`Snake Tauri frontend bootstrap failed: ${message}`);
|
||||||
|
});
|
||||||
97
crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts
Normal file
97
crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import type { SnakeWasmGame } from "@snake-wasm";
|
||||||
|
import type { RuntimeDescriptor } from "./bridge";
|
||||||
|
import { frontendInfo } from "./logging";
|
||||||
|
|
||||||
|
export type SnakeInputSource = "keyboard-mouse" | "touch";
|
||||||
|
|
||||||
|
type DeviceClass = "desktop" | "phone" | "tablet" | "unknown";
|
||||||
|
type InputProfile = "keyboard-mouse" | "mixed" | "touch" | "unknown";
|
||||||
|
|
||||||
|
function detectedDeviceClass(): DeviceClass {
|
||||||
|
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
const finePointer = window.matchMedia("(pointer: fine)").matches;
|
||||||
|
if (!coarsePointer || finePointer) {
|
||||||
|
return "desktop";
|
||||||
|
}
|
||||||
|
const shortEdge = Math.min(window.screen.width, window.screen.height);
|
||||||
|
if (!Number.isFinite(shortEdge) || shortEdge <= 0) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
return shortEdge < 768 ? "phone" : "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputProfile(keyboardMouseObserved: boolean, touchObserved: boolean): InputProfile {
|
||||||
|
if (keyboardMouseObserved && touchObserved) {
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
if (keyboardMouseObserved) {
|
||||||
|
return "keyboard-mouse";
|
||||||
|
}
|
||||||
|
if (touchObserved) {
|
||||||
|
return "touch";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayLabel(game: SnakeWasmGame): string {
|
||||||
|
return [
|
||||||
|
game.provenance_platform_family(),
|
||||||
|
game.provenance_runtime_host(),
|
||||||
|
game.provenance_execution_model(),
|
||||||
|
game.provenance_device_class(),
|
||||||
|
game.provenance_input_profile(),
|
||||||
|
].join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TauriRuntimeProvenance {
|
||||||
|
private keyboardMouseObserved = false;
|
||||||
|
private touchObserved = false;
|
||||||
|
private deviceClass: DeviceClass = detectedDeviceClass();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly game: SnakeWasmGame,
|
||||||
|
private readonly output: HTMLOutputElement,
|
||||||
|
private readonly descriptor: RuntimeDescriptor,
|
||||||
|
) {
|
||||||
|
if (descriptor.executionModel !== "wasm") {
|
||||||
|
throw new Error(`Modèle d'exécution Tauri inattendu : ${descriptor.executionModel}`);
|
||||||
|
}
|
||||||
|
this.synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public recordInput(source: SnakeInputSource): void {
|
||||||
|
if (source === "touch") {
|
||||||
|
if (this.touchObserved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.touchObserved = true;
|
||||||
|
} else {
|
||||||
|
if (this.keyboardMouseObserved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.keyboardMouseObserved = true;
|
||||||
|
}
|
||||||
|
this.synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public refreshDeviceClass(): void {
|
||||||
|
const nextDeviceClass = detectedDeviceClass();
|
||||||
|
if (nextDeviceClass === this.deviceClass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deviceClass = nextDeviceClass;
|
||||||
|
this.synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronize(): void {
|
||||||
|
const profile = inputProfile(this.keyboardMouseObserved, this.touchObserved);
|
||||||
|
if (!this.game.configure_runtime_provenance(this.descriptor.platformFamily, this.descriptor.runtimeHost, this.deviceClass, profile)) {
|
||||||
|
throw new Error("La provenance Tauri Android n'a pas pu être configurée dans le bridge WASM.");
|
||||||
|
}
|
||||||
|
this.output.value = displayLabel(this.game);
|
||||||
|
frontendInfo(`Snake Tauri provenance: ${this.output.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
crates/apps/game-snake-poc-tauri/icons/icon.png
Normal file
BIN
crates/apps/game-snake-poc-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 B |
22
crates/apps/game-snake-poc-tauri/package.json
Normal file
22
crates/apps/game-snake-poc-tauri/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "game-snake-poc-tauri",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.3.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"wasm:dev": "cargo build -p game-snake-poc-wasm --target wasm32-unknown-unknown && wasm-bindgen ../../../../builds/sasedev-games/target/wasm32-unknown-unknown/debug/game_snake_poc_wasm.wasm --target web --out-dir ../../../../builds/sasedev-games/game-snake-poc-tauri/wasm --out-name game_snake_poc_wasm",
|
||||||
|
"wasm:build": "cargo build -p game-snake-poc-wasm --release --target wasm32-unknown-unknown && wasm-bindgen ../../../../builds/sasedev-games/target/wasm32-unknown-unknown/release/game_snake_poc_wasm.wasm --target web --out-dir ../../../../builds/sasedev-games/game-snake-poc-tauri/wasm --out-name game_snake_poc_wasm",
|
||||||
|
"dev": "npm run wasm:dev && vite",
|
||||||
|
"build": "npm run wasm:build && tsc && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fltsci/tauri-plugin-tracing": "^0.3",
|
||||||
|
"@tauri-apps/api": "^2.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1",
|
||||||
|
"typescript": "^7.0",
|
||||||
|
"vite": "^8.2",
|
||||||
|
"vite-plugin-static-copy": "^4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/apps/game-snake-poc-tauri/src/lib.rs
Normal file
21
crates/apps/game-snake-poc-tauri/src/lib.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/src/lib.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Tauri Android application facade for the Snake WebAssembly POC.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod runtime;
|
||||||
|
mod tauri;
|
||||||
|
|
||||||
|
/// Runs the Snake Tauri application.
|
||||||
|
pub use self::tauri::run;
|
||||||
|
|
||||||
|
/// Runtime descriptor exposed by the native Tauri bridge.
|
||||||
|
pub(crate) use self::runtime::RuntimeDescriptorDto;
|
||||||
|
/// Records that the Tauri frontend completed its minimal startup sequence.
|
||||||
|
pub(crate) use self::runtime::record_frontend_ready;
|
||||||
|
/// Returns the canonical runtime descriptor for the Android WebView host.
|
||||||
|
pub(crate) use self::runtime::runtime_descriptor;
|
||||||
20
crates/apps/game-snake-poc-tauri/src/main.rs
Normal file
20
crates/apps/game-snake-poc-tauri/src/main.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Binary entry point for host-side Snake Tauri checks.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() -> std::process::ExitCode {
|
||||||
|
let result = game_snake_poc_tauri_lib::run();
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("Snake Tauri application error: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
87
crates/apps/game-snake-poc-tauri/src/runtime.rs
Normal file
87
crates/apps/game-snake-poc-tauri/src/runtime.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/src/runtime.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Stable runtime dimensions exposed to the Tauri frontend.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct RuntimeDescriptorDto {
|
||||||
|
device_class: String,
|
||||||
|
execution_model: String,
|
||||||
|
input_profile: String,
|
||||||
|
platform_family: String,
|
||||||
|
runtime_host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical runtime descriptor for the Android WebView host.
|
||||||
|
pub(crate) fn runtime_descriptor() -> RuntimeDescriptorDto {
|
||||||
|
let provenance = engine_v1_platform_api::RuntimeProvenance::new(
|
||||||
|
engine_v1_platform_api::DeviceClass::Unknown,
|
||||||
|
engine_v1_platform_api::ExecutionModel::Wasm,
|
||||||
|
engine_v1_platform_api::InputProfile::Unknown,
|
||||||
|
engine_v1_platform_api::PlatformFamily::Android,
|
||||||
|
engine_v1_platform_api::RuntimeHost::TauriWebView,
|
||||||
|
);
|
||||||
|
return RuntimeDescriptorDto {
|
||||||
|
device_class: device_class_label(provenance.device_class()).to_string(),
|
||||||
|
execution_model: execution_model_label(provenance.execution_model()).to_string(),
|
||||||
|
input_profile: input_profile_label(provenance.input_profile()).to_string(),
|
||||||
|
platform_family: platform_family_label(provenance.platform_family()).to_string(),
|
||||||
|
runtime_host: runtime_host_label(provenance.runtime_host()).to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that the Tauri frontend completed its minimal startup sequence.
|
||||||
|
pub(crate) fn record_frontend_ready() {
|
||||||
|
let descriptor = crate::runtime_descriptor();
|
||||||
|
tracing::info!(
|
||||||
|
target: "games::tauri::snake",
|
||||||
|
action = "frontend_ready",
|
||||||
|
platform_family = descriptor.platform_family,
|
||||||
|
runtime_host = descriptor.runtime_host,
|
||||||
|
execution_model = descriptor.execution_model,
|
||||||
|
"Snake Tauri frontend ready"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_class_label(value: engine_v1_platform_api::DeviceClass) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::DeviceClass::Desktop => "desktop",
|
||||||
|
engine_v1_platform_api::DeviceClass::Phone => "phone",
|
||||||
|
engine_v1_platform_api::DeviceClass::Tablet => "tablet",
|
||||||
|
engine_v1_platform_api::DeviceClass::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execution_model_label(value: engine_v1_platform_api::ExecutionModel) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::ExecutionModel::Native => "native",
|
||||||
|
engine_v1_platform_api::ExecutionModel::Wasm => "wasm",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_profile_label(value: engine_v1_platform_api::InputProfile) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::InputProfile::Gamepad => "gamepad",
|
||||||
|
engine_v1_platform_api::InputProfile::KeyboardMouse => "keyboard-mouse",
|
||||||
|
engine_v1_platform_api::InputProfile::Mixed => "mixed",
|
||||||
|
engine_v1_platform_api::InputProfile::Touch => "touch",
|
||||||
|
engine_v1_platform_api::InputProfile::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_family_label(value: engine_v1_platform_api::PlatformFamily) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::PlatformFamily::Android => "android",
|
||||||
|
engine_v1_platform_api::PlatformFamily::Desktop => "desktop",
|
||||||
|
engine_v1_platform_api::PlatformFamily::Web => "web",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_host_label(value: engine_v1_platform_api::RuntimeHost) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::RuntimeHost::Browser => "browser",
|
||||||
|
engine_v1_platform_api::RuntimeHost::Native => "native",
|
||||||
|
engine_v1_platform_api::RuntimeHost::TauriWebView => "tauri-webview",
|
||||||
|
};
|
||||||
|
}
|
||||||
85
crates/apps/game-snake-poc-tauri/src/tauri.rs
Normal file
85
crates/apps/game-snake-poc-tauri/src/tauri.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/src/tauri.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Tauri runtime assembly and Web/Rust commands for the Snake Android POC.
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_runtime_descriptor() -> crate::RuntimeDescriptorDto {
|
||||||
|
return crate::runtime_descriptor();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn frontend_ready() {
|
||||||
|
crate::record_frontend_ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
|
||||||
|
return builder.plugin(tracing_plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for command dispatch.
|
||||||
|
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
return builder.invoke_handler(tauri::generate_handler![get_runtime_descriptor, frontend_ready]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_runtime() -> std::result::Result<(), String> {
|
||||||
|
let logging_guard = game_logging_lib::init_console_tracing();
|
||||||
|
let _logging_guard = match logging_guard {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
target: "games::tauri::snake",
|
||||||
|
action = "runtime_start",
|
||||||
|
platform_family = "android",
|
||||||
|
runtime_host = "tauri-webview",
|
||||||
|
execution_model = "wasm",
|
||||||
|
"Starting Snake Tauri Android runtime"
|
||||||
|
);
|
||||||
|
let builder = tauri::Builder::default();
|
||||||
|
let builder = configure_plugins(builder);
|
||||||
|
let builder = configure_commands(builder);
|
||||||
|
return match builder.run(tauri::generate_context!()) {
|
||||||
|
std::result::Result::Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "games::tauri::snake",
|
||||||
|
action = "runtime_stop",
|
||||||
|
"Snake Tauri runtime stopped"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "games::tauri::snake",
|
||||||
|
action = "runtime_error",
|
||||||
|
error = error.to_string(),
|
||||||
|
"Snake Tauri runtime failed"
|
||||||
|
);
|
||||||
|
std::result::Result::Err(error.to_string())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the Snake Tauri application on a desktop host.
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
pub fn run() -> std::result::Result<(), String> {
|
||||||
|
return run_runtime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the Snake Tauri application from Tauri's mobile entry point.
|
||||||
|
#[cfg(mobile)]
|
||||||
|
#[tauri::mobile_entry_point]
|
||||||
|
pub fn run() {
|
||||||
|
let result = run_runtime();
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
tracing::error!(
|
||||||
|
target: "games::tauri::snake",
|
||||||
|
action = "mobile_entrypoint_error",
|
||||||
|
error = error.as_str(),
|
||||||
|
"Snake Tauri mobile entry point terminated after a runtime error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
41
crates/apps/game-snake-poc-tauri/tauri.conf.json
Normal file
41
crates/apps/game-snake-poc-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Snake POC Tauri",
|
||||||
|
"identifier": "com.sasedev.games.snake.tauri",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": {
|
||||||
|
"script": "npm run dev",
|
||||||
|
"cwd": "."
|
||||||
|
},
|
||||||
|
"devUrl": "http://localhost:1436",
|
||||||
|
"beforeBuildCommand": {
|
||||||
|
"script": "npm run build",
|
||||||
|
"cwd": "."
|
||||||
|
},
|
||||||
|
"frontendDist": "../../../../builds/sasedev-games/game-snake-poc-tauri/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"url": "main.html",
|
||||||
|
"title": "Snake POC Tauri",
|
||||||
|
"width": 405,
|
||||||
|
"height": 720,
|
||||||
|
"minWidth": 320,
|
||||||
|
"minHeight": 480,
|
||||||
|
"resizable": true,
|
||||||
|
"center": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false,
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/apps/game-snake-poc-tauri/tsconfig.json
Normal file
36
crates/apps/game-snake-poc-tauri/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@snake-wasm": [
|
||||||
|
"../../../../builds/sasedev-games/game-snake-poc-tauri/wasm/game_snake_poc_wasm.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"frontend",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
84
crates/apps/game-snake-poc-tauri/vite.config.ts
Normal file
84
crates/apps/game-snake-poc-tauri/vite.config.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-tauri/vite.config.ts
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { defineConfig, normalizePath } from "vite";
|
||||||
|
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const repositoryRoot = normalizePath(resolve(appRoot, "../../.."));
|
||||||
|
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||||
|
const externalBuildRoot = normalizePath(resolve(repositoryRoot, "../builds/sasedev-games/game-snake-poc-tauri"));
|
||||||
|
const wasmRoot = normalizePath(resolve(externalBuildRoot, "wasm"));
|
||||||
|
const wasmModule = normalizePath(resolve(wasmRoot, "game_snake_poc_wasm.js"));
|
||||||
|
const frontendDist = normalizePath(resolve(externalBuildRoot, "dist"));
|
||||||
|
const viteCacheDir = normalizePath(resolve(externalBuildRoot, "vite-cache"));
|
||||||
|
const commonRuntimeAsset = normalizePath(resolve(repositoryRoot, "assets/common/data/runtime.json"));
|
||||||
|
const gameRuntimeAsset = normalizePath(resolve(repositoryRoot, "assets/game-snake-poc/data/game.json"));
|
||||||
|
const devHost = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{ src: commonRuntimeAsset, dest: "common/data", rename: { stripBase: true } },
|
||||||
|
{ src: gameRuntimeAsset, dest: "game/data", rename: { stripBase: true } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
base: "./",
|
||||||
|
cacheDir: viteCacheDir,
|
||||||
|
clearScreen: false,
|
||||||
|
root: frontendRoot,
|
||||||
|
publicDir: false,
|
||||||
|
input: {
|
||||||
|
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@snake-wasm": wasmModule,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: frontendDist,
|
||||||
|
emptyOutDir: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "js/[name]-[hash].js",
|
||||||
|
chunkFileNames: "js/chunks/[name]-[hash].js",
|
||||||
|
assetFileNames: assetInfo => {
|
||||||
|
const originalName = assetInfo.names[0] ?? "";
|
||||||
|
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
if (extension === "css") {
|
||||||
|
return "css/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (extension === "wasm") {
|
||||||
|
return "wasm/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
return "otherassets/[name][extname]";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 1436,
|
||||||
|
strictPort: true,
|
||||||
|
host: devHost || false,
|
||||||
|
fs: {
|
||||||
|
allow: [appRoot, repositoryRoot, externalBuildRoot],
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
protocol: "ws",
|
||||||
|
host: devHost || "127.0.0.1",
|
||||||
|
port: 1436,
|
||||||
|
clientPort: 1436,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/src/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
23
crates/apps/game-snake-poc-wasm/Cargo.toml
Normal file
23
crates/apps/game-snake-poc-wasm/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# file: crates/apps/game-snake-poc-wasm/Cargo.toml
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-snake-poc-wasm"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
engine-v1-common = { path = "../../engines/engine-v1-common" }
|
||||||
|
engine-v1-platform-api = { path = "../../engines/engine-v1-platform-api" }
|
||||||
|
game-snake-poc = { path = "../../games/game-snake-poc" }
|
||||||
|
wasm-bindgen.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
13
crates/apps/game-snake-poc-wasm/src/lib.rs
Normal file
13
crates/apps/game-snake-poc-wasm/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-wasm/src/lib.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! WebAssembly adapter facade for the Snake POC.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod runtime;
|
||||||
|
|
||||||
|
/// Re-export of the Snake WebAssembly runtime adapter.
|
||||||
|
pub use self::runtime::SnakeWasmGame;
|
||||||
330
crates/apps/game-snake-poc-wasm/src/runtime.rs
Normal file
330
crates/apps/game-snake-poc-wasm/src/runtime.rs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-wasm/src/runtime.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
const FIXED_STEP_MILLIS: u64 = 16;
|
||||||
|
|
||||||
|
/// WebAssembly-owned Snake game state consumed by the direct browser host.
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub struct SnakeWasmGame {
|
||||||
|
pending_input: engine_v1_common::InputState,
|
||||||
|
provenance: engine_v1_platform_api::RuntimeProvenance,
|
||||||
|
runner: engine_v1_common::FixedStepRunner,
|
||||||
|
state: game_snake_poc::SnakeState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
impl SnakeWasmGame {
|
||||||
|
/// Creates a fresh Snake POC session using the shared gameplay crate.
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen(constructor)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
return Self {
|
||||||
|
pending_input: engine_v1_common::InputState::none(),
|
||||||
|
provenance: engine_v1_platform_api::RuntimeProvenance::new(
|
||||||
|
engine_v1_platform_api::DeviceClass::Unknown,
|
||||||
|
engine_v1_platform_api::ExecutionModel::Wasm,
|
||||||
|
engine_v1_platform_api::InputProfile::Unknown,
|
||||||
|
engine_v1_platform_api::PlatformFamily::Web,
|
||||||
|
engine_v1_platform_api::RuntimeHost::Browser,
|
||||||
|
),
|
||||||
|
runner: engine_v1_common::FixedStepRunner::new(std::time::Duration::from_millis(FIXED_STEP_MILLIS)),
|
||||||
|
state: game_snake_poc::SnakeState::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates host-observed runtime provenance while preserving WebAssembly execution.
|
||||||
|
pub fn configure_runtime_provenance(&mut self, platform_family: &str, runtime_host: &str, device_class: &str, input_profile: &str) -> bool {
|
||||||
|
let platform_family = match parse_platform_family(platform_family) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let runtime_host = match parse_runtime_host(runtime_host) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let device_class = match parse_device_class(device_class) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let input_profile = match parse_input_profile(input_profile) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
self.provenance = engine_v1_platform_api::RuntimeProvenance::new(
|
||||||
|
device_class,
|
||||||
|
engine_v1_platform_api::ExecutionModel::Wasm,
|
||||||
|
input_profile,
|
||||||
|
platform_family,
|
||||||
|
runtime_host,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current physical device class label.
|
||||||
|
pub fn provenance_device_class(&self) -> String {
|
||||||
|
return device_class_label(self.provenance.device_class()).to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current execution model label.
|
||||||
|
pub fn provenance_execution_model(&self) -> String {
|
||||||
|
return execution_model_label(self.provenance.execution_model()).to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current primary input profile label.
|
||||||
|
pub fn provenance_input_profile(&self) -> String {
|
||||||
|
return input_profile_label(self.provenance.input_profile()).to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current platform family label.
|
||||||
|
pub fn provenance_platform_family(&self) -> String {
|
||||||
|
return platform_family_label(self.provenance.platform_family()).to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current runtime host label.
|
||||||
|
pub fn provenance_runtime_host(&self) -> String {
|
||||||
|
return runtime_host_label(self.provenance.runtime_host()).to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a logical left direction for the next deterministic update.
|
||||||
|
pub fn left(&mut self) {
|
||||||
|
self.queue_direction(engine_v1_common::GameAction::Left);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a logical right direction for the next deterministic update.
|
||||||
|
pub fn right(&mut self) {
|
||||||
|
self.queue_direction(engine_v1_common::GameAction::Right);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a logical upward direction for the next deterministic update.
|
||||||
|
pub fn up(&mut self) {
|
||||||
|
self.queue_direction(engine_v1_common::GameAction::Up);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a logical downward direction for the next deterministic update.
|
||||||
|
pub fn down(&mut self) {
|
||||||
|
self.queue_direction(engine_v1_common::GameAction::Down);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances one deterministic update and consumes the queued direction.
|
||||||
|
pub fn tick(&mut self) {
|
||||||
|
let input = self.pending_input;
|
||||||
|
self.pending_input = engine_v1_common::InputState::none();
|
||||||
|
self.runner.tick(&mut self.state, input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current gameplay score.
|
||||||
|
pub fn score(&self) -> u32 {
|
||||||
|
return self.state.score().min(u32::MAX as u64) as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current Snake length.
|
||||||
|
pub fn length(&self) -> u32 {
|
||||||
|
return self.state.length().min(u32::MAX as usize) as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current scene background red channel.
|
||||||
|
pub fn background_red(&self) -> u8 {
|
||||||
|
return self.scene().background().red();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current scene background green channel.
|
||||||
|
pub fn background_green(&self) -> u8 {
|
||||||
|
return self.scene().background().green();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current scene background blue channel.
|
||||||
|
pub fn background_blue(&self) -> u8 {
|
||||||
|
return self.scene().background().blue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of occupied rectangle slots in the current scene.
|
||||||
|
pub fn rectangle_count(&self) -> u32 {
|
||||||
|
let scene = self.scene();
|
||||||
|
let mut count = 0_u32;
|
||||||
|
for slot in scene.rectangles() {
|
||||||
|
if slot.is_some() {
|
||||||
|
count = count.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's normalized left coordinate or `-1.0` if absent.
|
||||||
|
pub fn rectangle_x(&self, index: u32) -> f32 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.rect().x(),
|
||||||
|
None => -1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's normalized top coordinate or `-1.0` if absent.
|
||||||
|
pub fn rectangle_y(&self, index: u32) -> f32 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.rect().y(),
|
||||||
|
None => -1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's normalized width or `0.0` if absent.
|
||||||
|
pub fn rectangle_width(&self, index: u32) -> f32 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.rect().width(),
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's normalized height or `0.0` if absent.
|
||||||
|
pub fn rectangle_height(&self, index: u32) -> f32 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.rect().height(),
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's red channel or zero if absent.
|
||||||
|
pub fn rectangle_red(&self, index: u32) -> u8 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.color().red(),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's green channel or zero if absent.
|
||||||
|
pub fn rectangle_green(&self, index: u32) -> u8 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.color().green(),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one rectangle's blue channel or zero if absent.
|
||||||
|
pub fn rectangle_blue(&self, index: u32) -> u8 {
|
||||||
|
return match self.rectangle(index) {
|
||||||
|
Some(rectangle) => rectangle.color().blue(),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SnakeWasmGame {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnakeWasmGame {
|
||||||
|
fn queue_direction(&mut self, action: engine_v1_common::GameAction) {
|
||||||
|
self.pending_input = engine_v1_common::InputState::none().with_action(action, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene(&self) -> engine_v1_common::EngineScene {
|
||||||
|
return engine_v1_common::EngineGame::scene(&self.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rectangle(&self, requested_index: u32) -> Option<engine_v1_common::RenderRect> {
|
||||||
|
let scene = self.scene();
|
||||||
|
let mut occupied_index = 0_u32;
|
||||||
|
for slot in scene.rectangles() {
|
||||||
|
match slot {
|
||||||
|
Some(rectangle) => {
|
||||||
|
if occupied_index == requested_index {
|
||||||
|
return Some(*rectangle);
|
||||||
|
}
|
||||||
|
occupied_index = occupied_index.saturating_add(1);
|
||||||
|
},
|
||||||
|
None => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_platform_family(label: &str) -> Option<engine_v1_platform_api::PlatformFamily> {
|
||||||
|
return match label {
|
||||||
|
"android" => Some(engine_v1_platform_api::PlatformFamily::Android),
|
||||||
|
"desktop" => Some(engine_v1_platform_api::PlatformFamily::Desktop),
|
||||||
|
"web" => Some(engine_v1_platform_api::PlatformFamily::Web),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_runtime_host(label: &str) -> Option<engine_v1_platform_api::RuntimeHost> {
|
||||||
|
return match label {
|
||||||
|
"browser" => Some(engine_v1_platform_api::RuntimeHost::Browser),
|
||||||
|
"native" => Some(engine_v1_platform_api::RuntimeHost::Native),
|
||||||
|
"tauri-webview" => Some(engine_v1_platform_api::RuntimeHost::TauriWebView),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_device_class(label: &str) -> Option<engine_v1_platform_api::DeviceClass> {
|
||||||
|
return match label {
|
||||||
|
"desktop" => Some(engine_v1_platform_api::DeviceClass::Desktop),
|
||||||
|
"phone" => Some(engine_v1_platform_api::DeviceClass::Phone),
|
||||||
|
"tablet" => Some(engine_v1_platform_api::DeviceClass::Tablet),
|
||||||
|
"unknown" => Some(engine_v1_platform_api::DeviceClass::Unknown),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_input_profile(label: &str) -> Option<engine_v1_platform_api::InputProfile> {
|
||||||
|
return match label {
|
||||||
|
"gamepad" => Some(engine_v1_platform_api::InputProfile::Gamepad),
|
||||||
|
"keyboard-mouse" => Some(engine_v1_platform_api::InputProfile::KeyboardMouse),
|
||||||
|
"mixed" => Some(engine_v1_platform_api::InputProfile::Mixed),
|
||||||
|
"touch" => Some(engine_v1_platform_api::InputProfile::Touch),
|
||||||
|
"unknown" => Some(engine_v1_platform_api::InputProfile::Unknown),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_class_label(value: engine_v1_platform_api::DeviceClass) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::DeviceClass::Desktop => "desktop",
|
||||||
|
engine_v1_platform_api::DeviceClass::Phone => "phone",
|
||||||
|
engine_v1_platform_api::DeviceClass::Tablet => "tablet",
|
||||||
|
engine_v1_platform_api::DeviceClass::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execution_model_label(value: engine_v1_platform_api::ExecutionModel) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::ExecutionModel::Native => "native",
|
||||||
|
engine_v1_platform_api::ExecutionModel::Wasm => "wasm",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_profile_label(value: engine_v1_platform_api::InputProfile) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::InputProfile::Gamepad => "gamepad",
|
||||||
|
engine_v1_platform_api::InputProfile::KeyboardMouse => "keyboard-mouse",
|
||||||
|
engine_v1_platform_api::InputProfile::Mixed => "mixed",
|
||||||
|
engine_v1_platform_api::InputProfile::Touch => "touch",
|
||||||
|
engine_v1_platform_api::InputProfile::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_family_label(value: engine_v1_platform_api::PlatformFamily) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::PlatformFamily::Android => "android",
|
||||||
|
engine_v1_platform_api::PlatformFamily::Desktop => "desktop",
|
||||||
|
engine_v1_platform_api::PlatformFamily::Web => "web",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_host_label(value: engine_v1_platform_api::RuntimeHost) -> &'static str {
|
||||||
|
return match value {
|
||||||
|
engine_v1_platform_api::RuntimeHost::Browser => "browser",
|
||||||
|
engine_v1_platform_api::RuntimeHost::Native => "native",
|
||||||
|
engine_v1_platform_api::RuntimeHost::TauriWebView => "tauri-webview",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/runtime.rs"]
|
||||||
|
mod tests;
|
||||||
63
crates/apps/game-snake-poc-wasm/unit_tests/runtime.rs
Normal file
63
crates/apps/game-snake-poc-wasm/unit_tests/runtime.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// file: crates/apps/game-snake-poc-wasm/unit_tests/runtime.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direction_is_queued_without_advancing_simulation() {
|
||||||
|
let mut game = crate::SnakeWasmGame::new();
|
||||||
|
let before_x = game.rectangle_x(1);
|
||||||
|
let before_y = game.rectangle_y(1);
|
||||||
|
game.up();
|
||||||
|
assert_eq!(game.rectangle_x(1), before_x);
|
||||||
|
assert_eq!(game.rectangle_y(1), before_y);
|
||||||
|
game.tick();
|
||||||
|
assert_eq!(game.rectangle_x(1), before_x);
|
||||||
|
assert!(game.rectangle_y(1) < before_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_and_status_are_exposed_for_canvas_host() {
|
||||||
|
let game = crate::SnakeWasmGame::new();
|
||||||
|
assert_eq!(game.score(), 0);
|
||||||
|
assert_eq!(game.length(), 3);
|
||||||
|
assert_eq!(game.rectangle_count(), 4);
|
||||||
|
assert!(game.rectangle_width(0) > 0.0);
|
||||||
|
assert!(game.rectangle_height(0) > 0.0);
|
||||||
|
assert_eq!(game.rectangle_x(99), -1.0);
|
||||||
|
assert_eq!(game.rectangle_y(99), -1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_runtime_provenance_preserves_static_and_observed_dimensions() {
|
||||||
|
let mut game = crate::SnakeWasmGame::new();
|
||||||
|
assert_eq!(game.provenance_platform_family(), "web");
|
||||||
|
assert_eq!(game.provenance_execution_model(), "wasm");
|
||||||
|
assert_eq!(game.provenance_runtime_host(), "browser");
|
||||||
|
assert_eq!(game.provenance_device_class(), "unknown");
|
||||||
|
assert_eq!(game.provenance_input_profile(), "unknown");
|
||||||
|
assert!(game.configure_runtime_provenance("web", "browser", "phone", "touch"));
|
||||||
|
assert_eq!(game.provenance_device_class(), "phone");
|
||||||
|
assert_eq!(game.provenance_input_profile(), "touch");
|
||||||
|
assert_eq!(game.provenance_platform_family(), "web");
|
||||||
|
assert_eq!(game.provenance_execution_model(), "wasm");
|
||||||
|
assert_eq!(game.provenance_runtime_host(), "browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_runtime_provenance_labels_are_rejected_without_mutation() {
|
||||||
|
let mut game = crate::SnakeWasmGame::new();
|
||||||
|
assert!(game.configure_runtime_provenance("web", "browser", "desktop", "keyboard-mouse"));
|
||||||
|
assert!(!game.configure_runtime_provenance("console", "spaceship", "console", "telepathy"));
|
||||||
|
assert_eq!(game.provenance_device_class(), "desktop");
|
||||||
|
assert_eq!(game.provenance_input_profile(), "keyboard-mouse");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tauri_android_runtime_provenance_is_supported_by_the_shared_adapter() {
|
||||||
|
let mut game = crate::SnakeWasmGame::new();
|
||||||
|
assert!(game.configure_runtime_provenance("android", "tauri-webview", "phone", "touch"));
|
||||||
|
assert_eq!(game.provenance_platform_family(), "android");
|
||||||
|
assert_eq!(game.provenance_runtime_host(), "tauri-webview");
|
||||||
|
assert_eq!(game.provenance_execution_model(), "wasm");
|
||||||
|
assert_eq!(game.provenance_device_class(), "phone");
|
||||||
|
assert_eq!(game.provenance_input_profile(), "touch");
|
||||||
|
}
|
||||||
14
crates/common/game-realtime-transport-lib/Cargo.toml
Normal file
14
crates/common/game-realtime-transport-lib/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# file: crates/common/game-realtime-transport-lib/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-transport-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
42
crates/common/game-realtime-transport-lib/README.md
Normal file
42
crates/common/game-realtime-transport-lib/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- file: crates/common/game-realtime-transport-lib/README.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# game-realtime-transport-lib
|
||||||
|
|
||||||
|
Contrat realtime transport-neutral de `games.sasedev`. La crate définit uniquement la forme minimale d'une connexion établie, de ses moitiés d'envoi/réception, des payloads binaires opaques et des erreurs stables visibles par les couches supérieures.
|
||||||
|
|
||||||
|
## Responsabilité
|
||||||
|
|
||||||
|
La crate possède :
|
||||||
|
|
||||||
|
- `TransportMessage`, buffer binaire possédé ;
|
||||||
|
- `TransportReceive`, qui distingue message reçu et fermeture distante propre ;
|
||||||
|
- `RealtimeConnection`, `RealtimeSender` et `RealtimeReceiver` ;
|
||||||
|
- `TransportError` et `TransportErrorKind` comme catégories backend-neutral.
|
||||||
|
|
||||||
|
Elle ne possède pas :
|
||||||
|
|
||||||
|
- l'établissement d'une connexion réseau ;
|
||||||
|
- Tokio ou un autre runtime ;
|
||||||
|
- WebSocket, WebTransport, QUIC, HTTP ou TLS ;
|
||||||
|
- un codec wire ;
|
||||||
|
- une session joueur/room ;
|
||||||
|
- la synchronisation gameplay ou la simulation authoritative.
|
||||||
|
|
||||||
|
## Contrat async
|
||||||
|
|
||||||
|
`RealtimeConnection::split()` consomme une connexion établie et retourne des moitiés d'envoi et de réception indépendantes. Les opérations async sont exposées par des futures associées GAT plutôt que par `async-trait` ou `Box<dyn Future>`.
|
||||||
|
|
||||||
|
Le contrat n'impose volontairement aucune borne `Send` aux futures. Un backend natif peut fournir des futures `Send`, tandis qu'un futur backend navigateur/WASM ne doit pas être exclu par une contrainte de threading qui ne relève pas de l'abstraction transport.
|
||||||
|
|
||||||
|
## Sémantique
|
||||||
|
|
||||||
|
Les payloads sont toujours binaires et opaques. Une couche supérieure pourra ultérieurement leur appliquer un codec wire ou un protocole de session sans modifier cette crate.
|
||||||
|
|
||||||
|
Une fermeture distante propre est représentée par `TransportReceive::Closed`. Elle n'est pas convertie en erreur I/O générique. Les erreurs utilisent une catégorie stable (`Timeout`, `MessageTooLarge`, `Backpressure`, `Protocol`, etc.) et un détail de diagnostic, sans exposer le type d'erreur du backend concret.
|
||||||
|
|
||||||
|
## Dépendances et sens d'ownership
|
||||||
|
|
||||||
|
Cette crate ne dépend d'aucun backend realtime. Les implémentations concrètes dépendent d'elle, jamais l'inverse.
|
||||||
|
|
||||||
|
Les deux implémentations concrètes retenues à l’issue de `0.3.5` sont `game-realtime-websocket-lib`, baseline/fallback de référence, et `game-realtime-webtransport-lib`, second backend fiable. Les datagrams WebTransport restent volontairement hors de cette crate parce qu’ils ne partagent ni la fiabilité ni l’ordre garantis par `RealtimeConnection`.
|
||||||
42
crates/common/game-realtime-transport-lib/src/connection.rs
Normal file
42
crates/common/game-realtime-transport-lib/src/connection.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// file: crates/common/game-realtime-transport-lib/src/connection.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Established realtime connection that owns independent send and receive halves.
|
||||||
|
pub trait RealtimeConnection {
|
||||||
|
/// Send half produced when this connection is split.
|
||||||
|
type Sender: crate::RealtimeSender;
|
||||||
|
/// Receive half produced when this connection is split.
|
||||||
|
type Receiver: crate::RealtimeReceiver;
|
||||||
|
|
||||||
|
/// Consumes the connection and returns independent send and receive halves.
|
||||||
|
fn split(self) -> (Self::Sender, Self::Receiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send half of an established realtime connection.
|
||||||
|
pub trait RealtimeSender {
|
||||||
|
/// Future returned by [`RealtimeSender::send`].
|
||||||
|
type SendFuture<'a>: core::future::Future<Output = Result<(), crate::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
/// Future returned by [`RealtimeSender::close`].
|
||||||
|
type CloseFuture<'a>: core::future::Future<Output = Result<(), crate::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
/// Sends one owned opaque binary message, respecting backend backpressure.
|
||||||
|
fn send(&mut self, message: crate::TransportMessage) -> Self::SendFuture<'_>;
|
||||||
|
|
||||||
|
/// Initiates a clean local transport-level close.
|
||||||
|
fn close(&mut self) -> Self::CloseFuture<'_>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive half of an established realtime connection.
|
||||||
|
pub trait RealtimeReceiver {
|
||||||
|
/// Future returned by [`RealtimeReceiver::receive`].
|
||||||
|
type ReceiveFuture<'a>: core::future::Future<Output = Result<crate::TransportReceive, crate::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
/// Receives one binary message or observes a clean remote close.
|
||||||
|
fn receive(&mut self) -> Self::ReceiveFuture<'_>;
|
||||||
|
}
|
||||||
82
crates/common/game-realtime-transport-lib/src/error.rs
Normal file
82
crates/common/game-realtime-transport-lib/src/error.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// file: crates/common/game-realtime-transport-lib/src/error.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Stable transport-neutral category for a realtime operation failure.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TransportErrorKind {
|
||||||
|
/// An endpoint or transport configuration is invalid.
|
||||||
|
InvalidConfiguration,
|
||||||
|
/// A client connection could not be established.
|
||||||
|
Connect,
|
||||||
|
/// A server endpoint could not be bound.
|
||||||
|
Bind,
|
||||||
|
/// A server could not accept an incoming connection.
|
||||||
|
Accept,
|
||||||
|
/// An operation exceeded its configured deadline.
|
||||||
|
Timeout,
|
||||||
|
/// A payload exceeds the configured transport bound.
|
||||||
|
MessageTooLarge,
|
||||||
|
/// The transport cannot currently accept more outbound data within its configured bounds.
|
||||||
|
Backpressure,
|
||||||
|
/// The requested operation requires a connection that is already closed.
|
||||||
|
Closed,
|
||||||
|
/// An underlying I/O operation failed.
|
||||||
|
Io,
|
||||||
|
/// The backend reported a protocol-level or transport-specific failure.
|
||||||
|
Protocol,
|
||||||
|
/// The operation was explicitly aborted or cancelled when that distinction is observable.
|
||||||
|
Aborted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for TransportErrorKind {
|
||||||
|
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
return match self {
|
||||||
|
crate::TransportErrorKind::InvalidConfiguration => formatter.write_str("invalid configuration"),
|
||||||
|
crate::TransportErrorKind::Connect => formatter.write_str("connect failure"),
|
||||||
|
crate::TransportErrorKind::Bind => formatter.write_str("bind failure"),
|
||||||
|
crate::TransportErrorKind::Accept => formatter.write_str("accept failure"),
|
||||||
|
crate::TransportErrorKind::Timeout => formatter.write_str("operation timed out"),
|
||||||
|
crate::TransportErrorKind::MessageTooLarge => formatter.write_str("message too large"),
|
||||||
|
crate::TransportErrorKind::Backpressure => formatter.write_str("transport backpressure"),
|
||||||
|
crate::TransportErrorKind::Closed => formatter.write_str("connection closed"),
|
||||||
|
crate::TransportErrorKind::Io => formatter.write_str("I/O failure"),
|
||||||
|
crate::TransportErrorKind::Protocol => formatter.write_str("protocol failure"),
|
||||||
|
crate::TransportErrorKind::Aborted => formatter.write_str("operation aborted"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport-neutral operation failure with a stable category and diagnostic detail.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TransportError {
|
||||||
|
detail: String,
|
||||||
|
kind: crate::TransportErrorKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportError {
|
||||||
|
/// Creates a transport failure from a stable category and backend-neutral diagnostic detail.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(kind: crate::TransportErrorKind, detail: impl Into<String>) -> Self {
|
||||||
|
return Self { detail: detail.into(), kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrows the diagnostic detail associated with the failure.
|
||||||
|
#[must_use]
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
return self.detail.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable transport-neutral failure category.
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> crate::TransportErrorKind {
|
||||||
|
return self.kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for TransportError {
|
||||||
|
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
return write!(formatter, "{}: {}", self.kind, self.detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TransportError {}
|
||||||
31
crates/common/game-realtime-transport-lib/src/lib.rs
Normal file
31
crates/common/game-realtime-transport-lib/src/lib.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// file: crates/common/game-realtime-transport-lib/src/lib.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Transport-neutral realtime connection contracts for games.sasedev.
|
||||||
|
|
||||||
|
mod connection;
|
||||||
|
mod error;
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
/// Re-export of an established realtime connection that can be split into independent halves.
|
||||||
|
pub use self::connection::RealtimeConnection;
|
||||||
|
/// Re-export of the receive half of an established realtime connection.
|
||||||
|
pub use self::connection::RealtimeReceiver;
|
||||||
|
/// Re-export of the send half of an established realtime connection.
|
||||||
|
pub use self::connection::RealtimeSender;
|
||||||
|
/// Re-export of a transport-neutral operation failure.
|
||||||
|
pub use self::error::TransportError;
|
||||||
|
/// Re-export of transport-neutral error categories.
|
||||||
|
pub use self::error::TransportErrorKind;
|
||||||
|
/// Re-export of an owned opaque binary transport message.
|
||||||
|
pub use self::message::TransportMessage;
|
||||||
|
/// Re-export of the result of one receive operation.
|
||||||
|
pub use self::message::TransportReceive;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/contract.rs"]
|
||||||
|
mod tests;
|
||||||
49
crates/common/game-realtime-transport-lib/src/message.rs
Normal file
49
crates/common/game-realtime-transport-lib/src/message.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// file: crates/common/game-realtime-transport-lib/src/message.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Owned opaque binary payload carried by a realtime transport.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TransportMessage {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportMessage {
|
||||||
|
/// Creates a transport message from owned bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(bytes: Vec<u8>) -> Self {
|
||||||
|
return Self { bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrows the opaque payload bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
|
return self.bytes.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the message and returns its owned payload bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_bytes(self) -> Vec<u8> {
|
||||||
|
return self.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the payload is empty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
return self.bytes.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the payload length in bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
return self.bytes.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of one successful transport receive operation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TransportReceive {
|
||||||
|
/// One opaque binary message was received.
|
||||||
|
Message(crate::TransportMessage),
|
||||||
|
/// The remote peer completed a clean transport-level close.
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
131
crates/common/game-realtime-transport-lib/unit_tests/contract.rs
Normal file
131
crates/common/game-realtime-transport-lib/unit_tests/contract.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// file: crates/common/game-realtime-transport-lib/unit_tests/contract.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
struct TestSender {
|
||||||
|
closed: bool,
|
||||||
|
sent_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::RealtimeSender for TestSender {
|
||||||
|
type SendFuture<'a>
|
||||||
|
= std::future::Ready<Result<(), crate::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
type CloseFuture<'a>
|
||||||
|
= std::future::Ready<Result<(), crate::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn send(&mut self, message: crate::TransportMessage) -> Self::SendFuture<'_> {
|
||||||
|
self.sent_bytes = self.sent_bytes.saturating_add(message.len());
|
||||||
|
return std::future::ready(Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) -> Self::CloseFuture<'_> {
|
||||||
|
self.closed = true;
|
||||||
|
return std::future::ready(Ok(()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
struct TestReceiver;
|
||||||
|
|
||||||
|
impl crate::RealtimeReceiver for TestReceiver {
|
||||||
|
type ReceiveFuture<'a>
|
||||||
|
= std::future::Ready<Result<crate::TransportReceive, crate::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
|
||||||
|
return std::future::ready(Ok(crate::TransportReceive::Closed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestConnection;
|
||||||
|
|
||||||
|
impl crate::RealtimeConnection for TestConnection {
|
||||||
|
type Sender = TestSender;
|
||||||
|
type Receiver = TestReceiver;
|
||||||
|
|
||||||
|
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||||
|
return (TestSender { closed: false, sent_bytes: 0 }, TestReceiver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_ready<T>(future: impl core::future::Future<Output = T>) -> T {
|
||||||
|
let mut future = std::pin::pin!(future);
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
return match core::future::Future::poll(future.as_mut(), &mut context) {
|
||||||
|
std::task::Poll::Ready(value) => value,
|
||||||
|
std::task::Poll::Pending => unreachable!("test future unexpectedly remained pending"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_preserves_owned_binary_payload() {
|
||||||
|
let message = crate::TransportMessage::new(vec![0, 1, 2, 255]);
|
||||||
|
assert_eq!(message.as_bytes(), &[0, 1, 2, 255]);
|
||||||
|
assert_eq!(message.len(), 4);
|
||||||
|
assert!(!message.is_empty());
|
||||||
|
assert_eq!(message.into_bytes(), vec![0, 1, 2, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_message_remains_a_valid_transport_payload() {
|
||||||
|
let message = crate::TransportMessage::new(Vec::new());
|
||||||
|
assert!(message.is_empty());
|
||||||
|
assert_eq!(message.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_remote_close_is_distinct_from_transport_error() {
|
||||||
|
let mut receiver = TestReceiver;
|
||||||
|
let received = poll_ready(crate::RealtimeReceiver::receive(&mut receiver));
|
||||||
|
assert_eq!(received, Ok(crate::TransportReceive::Closed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_split_produces_independent_contract_halves() {
|
||||||
|
let (mut sender, mut receiver) = crate::RealtimeConnection::split(TestConnection);
|
||||||
|
assert_eq!(poll_ready(crate::RealtimeSender::send(&mut sender, crate::TransportMessage::new(vec![1, 2, 3]))), Ok(()));
|
||||||
|
assert_eq!(sender.sent_bytes, 3);
|
||||||
|
assert_eq!(poll_ready(crate::RealtimeReceiver::receive(&mut receiver)), Ok(crate::TransportReceive::Closed));
|
||||||
|
assert_eq!(poll_ready(crate::RealtimeSender::close(&mut sender)), Ok(()));
|
||||||
|
assert!(sender.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_receive_variant_preserves_payload() {
|
||||||
|
let received = crate::TransportReceive::Message(crate::TransportMessage::new(vec![4, 5, 6]));
|
||||||
|
assert_eq!(received, crate::TransportReceive::Message(crate::TransportMessage::new(vec![4, 5, 6])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_error_preserves_stable_kind_and_diagnostic_detail() {
|
||||||
|
let error = crate::TransportError::new(crate::TransportErrorKind::Timeout, "send deadline exceeded");
|
||||||
|
assert_eq!(error.kind(), crate::TransportErrorKind::Timeout);
|
||||||
|
assert_eq!(error.detail(), "send deadline exceeded");
|
||||||
|
assert_eq!(error.to_string(), "operation timed out: send deadline exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_error_kinds_have_stable_human_readable_labels() {
|
||||||
|
let cases = [
|
||||||
|
(crate::TransportErrorKind::InvalidConfiguration, "invalid configuration"),
|
||||||
|
(crate::TransportErrorKind::Connect, "connect failure"),
|
||||||
|
(crate::TransportErrorKind::Bind, "bind failure"),
|
||||||
|
(crate::TransportErrorKind::Accept, "accept failure"),
|
||||||
|
(crate::TransportErrorKind::Timeout, "operation timed out"),
|
||||||
|
(crate::TransportErrorKind::MessageTooLarge, "message too large"),
|
||||||
|
(crate::TransportErrorKind::Backpressure, "transport backpressure"),
|
||||||
|
(crate::TransportErrorKind::Closed, "connection closed"),
|
||||||
|
(crate::TransportErrorKind::Io, "I/O failure"),
|
||||||
|
(crate::TransportErrorKind::Protocol, "protocol failure"),
|
||||||
|
(crate::TransportErrorKind::Aborted, "operation aborted"),
|
||||||
|
];
|
||||||
|
for (kind, expected) in cases {
|
||||||
|
assert_eq!(kind.to_string(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/common/game-realtime-websocket-lib/Cargo.toml
Normal file
24
crates/common/game-realtime-websocket-lib/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# file: crates/common/game-realtime-websocket-lib/Cargo.toml
|
||||||
|
# version: 3
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-websocket-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
futures-util = { workspace = true, features = ["sink", "std"] }
|
||||||
|
game-realtime-transport-lib = { path = "../game-realtime-transport-lib" }
|
||||||
|
tokio = { workspace = true, features = ["net", "time"] }
|
||||||
|
tokio-tungstenite = { workspace = true, features = ["connect", "handshake"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
49
crates/common/game-realtime-websocket-lib/README.md
Normal file
49
crates/common/game-realtime-websocket-lib/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!-- file: crates/common/game-realtime-websocket-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# game-realtime-websocket-lib
|
||||||
|
|
||||||
|
Backend WebSocket de référence pour le contrat `game-realtime-transport-lib`, fondé sur Tokio et `tokio-tungstenite`.
|
||||||
|
|
||||||
|
## Responsabilité
|
||||||
|
|
||||||
|
La crate possède :
|
||||||
|
|
||||||
|
- connexion client `ws://` ;
|
||||||
|
- listener serveur TCP + upgrade WebSocket ;
|
||||||
|
- types concrets `WebSocketConnection`, `WebSocketSender` et `WebSocketReceiver` ;
|
||||||
|
- mapping WebSocket vers le contrat binaire transport-neutral ;
|
||||||
|
- limites de message/frame/write-buffer ;
|
||||||
|
- deadlines connect/handshake, send et close ;
|
||||||
|
- tracing du domaine `games::realtime::websocket` ;
|
||||||
|
- mapping des erreurs Tungstenite vers `TransportErrorKind`.
|
||||||
|
|
||||||
|
Elle ne possède pas le runtime Tokio : le consommateur crée et exécute son runtime. La crate ne lance ni runtime global, ni thread runtime privé, ni task backend détachée pour une connexion de base.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
Le backend transporte des octets opaques. Il ne connaît ni joueur, ni room, ni tick, ni snapshot, ni codec wire, ni protocole de session.
|
||||||
|
|
||||||
|
Les messages Text ne font pas partie du contrat et sont rejetés comme erreur de protocole. Ping/Pong reste un détail WebSocket. Une fermeture distante propre devient `TransportReceive::Closed`.
|
||||||
|
|
||||||
|
La baseline active utilise `ws://`. Aucune feature TLS de `tokio-tungstenite` n'est activée ; une politique `wss://` directe n'est pas introduite tant qu'un besoin produit et une stratégie de certificats ne sont pas démontrés.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`WebSocketConfig::default()` fournit des bornes produit explicites :
|
||||||
|
|
||||||
|
```text
|
||||||
|
message maximum 1 MiB
|
||||||
|
frame maximum 1 MiB
|
||||||
|
write buffer target 64 KiB
|
||||||
|
write buffer maximum 2 MiB
|
||||||
|
connect/handshake 10 s
|
||||||
|
send 5 s
|
||||||
|
close 2 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Les builders `with_*` permettent d'adapter ces limites avant `connect_with_config` ou `WebSocketListener::bind_with_config`. `validate()` refuse les configurations incohérentes avant l'établissement réseau.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
Voir [`USAGE.md`](USAGE.md) pour les points d'entrée client/serveur, la configuration et le smoke de référence.
|
||||||
78
crates/common/game-realtime-websocket-lib/USAGE.md
Normal file
78
crates/common/game-realtime-websocket-lib/USAGE.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!-- file: crates/common/game-realtime-websocket-lib/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation — backend realtime WebSocket
|
||||||
|
|
||||||
|
## Préconditions
|
||||||
|
|
||||||
|
Le consommateur possède le runtime Tokio. `game-realtime-websocket-lib` utilise Tokio pour les sockets et deadlines mais ne crée jamais son propre runtime.
|
||||||
|
|
||||||
|
Le contrat transport-neutral est fourni par `game-realtime-transport-lib`. Pour appeler `split`, `send`, `receive` et `close`, importer les traits correspondants dans le module consommateur selon les règles Rust du dépôt.
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
Le point d'entrée simple est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
game_realtime_websocket_lib::connect("ws://host:port/path")
|
||||||
|
```
|
||||||
|
|
||||||
|
La fonction retourne une `WebSocketConnection`. La connexion est ensuite consommée par `RealtimeConnection::split()` pour obtenir un sender et un receiver indépendants.
|
||||||
|
|
||||||
|
Pour une configuration spécifique, construire `WebSocketConfig`, valider ses invariants puis utiliser :
|
||||||
|
|
||||||
|
```text
|
||||||
|
game_realtime_websocket_lib::connect_with_config(endpoint, config)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serveur
|
||||||
|
|
||||||
|
Créer d'abord une adresse `SocketAddr`, puis binder :
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocketListener::bind(address)
|
||||||
|
```
|
||||||
|
|
||||||
|
ou :
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocketListener::bind_with_config(address, config)
|
||||||
|
```
|
||||||
|
|
||||||
|
`local_addr()` permet de connaître l'adresse réellement allouée, notamment après bind sur `127.0.0.1:0`. `accept().await` attend ensuite un peer TCP et effectue l'upgrade WebSocket dans la deadline configurée.
|
||||||
|
|
||||||
|
## Émission et réception
|
||||||
|
|
||||||
|
Un message applicatif est encapsulé dans `TransportMessage::new(Vec<u8>)`. Le sender refuse localement un payload dépassant les bornes configurées avant écriture.
|
||||||
|
|
||||||
|
`receive()` retourne :
|
||||||
|
|
||||||
|
- `TransportReceive::Message` pour un payload binaire ;
|
||||||
|
- `TransportReceive::Closed` pour une fermeture distante propre ;
|
||||||
|
- `TransportError` pour une erreur réseau/protocole, un timeout ou une limite dépassée.
|
||||||
|
|
||||||
|
Une frame Text reçue est une erreur de protocole : elle n'est jamais convertie en bytes métier.
|
||||||
|
|
||||||
|
## Fermeture
|
||||||
|
|
||||||
|
`RealtimeSender::close()` initie un close WebSocket propre et applique la deadline de fermeture configurée. Une task/future annulée n'est pas assimilée à un close handshake réussi.
|
||||||
|
|
||||||
|
## Smoke local
|
||||||
|
|
||||||
|
Le launcher de validation public utilise uniquement les API exposées par les deux crates realtime :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p game-realtime-websocket-smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Il bind `127.0.0.1:0`, connecte un client réel, échange un payload binaire dans les deux sens, initie un close propre et doit terminer avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
game-realtime-websocket-smoke: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke ne dépend ni d'Internet, ni d'un port fixe, ni d'un serveur externe.
|
||||||
|
|
||||||
|
## Limites intentionnelles
|
||||||
|
|
||||||
|
Cette crate n'est pas un protocole multijoueur. Authentification, versionnement wire, session joueur/room, heartbeat métier, resynchronisation, snapshots/deltas et simulation authoritative appartiennent aux couches supérieures et restent hors de son contrat.
|
||||||
167
crates/common/game-realtime-websocket-lib/src/config.rs
Normal file
167
crates/common/game-realtime-websocket-lib/src/config.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/src/config.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
const DEFAULT_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const DEFAULT_MAX_FRAME_SIZE: usize = 1024 * 1024;
|
||||||
|
const DEFAULT_MAX_MESSAGE_SIZE: usize = 1024 * 1024;
|
||||||
|
const DEFAULT_MAX_WRITE_BUFFER_SIZE: usize = 2 * 1024 * 1024;
|
||||||
|
const DEFAULT_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const DEFAULT_WRITE_BUFFER_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Product-facing limits and operation deadlines for one WebSocket connection.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct WebSocketConfig {
|
||||||
|
max_message_size: usize,
|
||||||
|
max_frame_size: usize,
|
||||||
|
write_buffer_size: usize,
|
||||||
|
max_write_buffer_size: usize,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
send_timeout: std::time::Duration,
|
||||||
|
close_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebSocketConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self {
|
||||||
|
max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
|
||||||
|
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
|
||||||
|
write_buffer_size: DEFAULT_WRITE_BUFFER_SIZE,
|
||||||
|
max_write_buffer_size: DEFAULT_MAX_WRITE_BUFFER_SIZE,
|
||||||
|
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
send_timeout: DEFAULT_SEND_TIMEOUT,
|
||||||
|
close_timeout: DEFAULT_CLOSE_TIMEOUT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketConfig {
|
||||||
|
/// Returns a copy with a different maximum binary message size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_max_message_size(mut self, value: usize) -> Self {
|
||||||
|
self.max_message_size = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different maximum WebSocket frame payload size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_max_frame_size(mut self, value: usize) -> Self {
|
||||||
|
self.max_frame_size = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different Tungstenite write-buffer target.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_write_buffer_size(mut self, value: usize) -> Self {
|
||||||
|
self.write_buffer_size = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different hard maximum for the Tungstenite write buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_max_write_buffer_size(mut self, value: usize) -> Self {
|
||||||
|
self.max_write_buffer_size = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different connection or handshake deadline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_connect_timeout(mut self, value: std::time::Duration) -> Self {
|
||||||
|
self.connect_timeout = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different deadline for one send operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_send_timeout(mut self, value: std::time::Duration) -> Self {
|
||||||
|
self.send_timeout = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different deadline for a clean local close operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_close_timeout(mut self, value: std::time::Duration) -> Self {
|
||||||
|
self.close_timeout = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum binary message size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_message_size(&self) -> usize {
|
||||||
|
return self.max_message_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum WebSocket frame payload size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_frame_size(&self) -> usize {
|
||||||
|
return self.max_frame_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured Tungstenite write-buffer target.
|
||||||
|
#[must_use]
|
||||||
|
pub fn write_buffer_size(&self) -> usize {
|
||||||
|
return self.write_buffer_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured hard maximum for the Tungstenite write buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_write_buffer_size(&self) -> usize {
|
||||||
|
return self.max_write_buffer_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured connection or handshake deadline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn connect_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.connect_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured deadline for one send operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn send_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.send_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured deadline for a clean local close operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn close_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.close_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates all invariants required before creating a WebSocket endpoint or connection.
|
||||||
|
pub fn validate(&self) -> Result<(), game_realtime_transport_lib::TransportError> {
|
||||||
|
if self.max_message_size == 0 {
|
||||||
|
return Err(invalid_configuration("max_message_size must be greater than zero"));
|
||||||
|
}
|
||||||
|
if self.max_frame_size == 0 {
|
||||||
|
return Err(invalid_configuration("max_frame_size must be greater than zero"));
|
||||||
|
}
|
||||||
|
if self.max_frame_size > self.max_message_size {
|
||||||
|
return Err(invalid_configuration("max_frame_size must not exceed max_message_size"));
|
||||||
|
}
|
||||||
|
let minimum_max_write_buffer_size = match self.write_buffer_size.checked_add(self.max_message_size) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Err(invalid_configuration("write_buffer_size + max_message_size overflows usize")),
|
||||||
|
};
|
||||||
|
if self.max_write_buffer_size < minimum_max_write_buffer_size {
|
||||||
|
return Err(invalid_configuration("max_write_buffer_size must fit write_buffer_size plus one maximum-sized message"));
|
||||||
|
}
|
||||||
|
if self.connect_timeout.is_zero() {
|
||||||
|
return Err(invalid_configuration("connect_timeout must be greater than zero"));
|
||||||
|
}
|
||||||
|
if self.send_timeout.is_zero() {
|
||||||
|
return Err(invalid_configuration("send_timeout must be greater than zero"));
|
||||||
|
}
|
||||||
|
if self.close_timeout.is_zero() {
|
||||||
|
return Err(invalid_configuration("close_timeout must be greater than zero"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_configuration(detail: &str) -> game_realtime_transport_lib::TransportError {
|
||||||
|
return game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/config.rs"]
|
||||||
|
mod tests;
|
||||||
26
crates/common/game-realtime-websocket-lib/src/lib.rs
Normal file
26
crates/common/game-realtime-websocket-lib/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/src/lib.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Tokio/tokio-tungstenite WebSocket backend for the games.sasedev realtime transport contract.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
/// Re-export of product-facing WebSocket limits and operation deadlines.
|
||||||
|
pub use self::config::WebSocketConfig;
|
||||||
|
/// Re-export of an established WebSocket transport connection.
|
||||||
|
pub use self::websocket::WebSocketConnection;
|
||||||
|
/// Re-export of a bound WebSocket server listener.
|
||||||
|
pub use self::websocket::WebSocketListener;
|
||||||
|
/// Re-export of the receive half of an established WebSocket connection.
|
||||||
|
pub use self::websocket::WebSocketReceiver;
|
||||||
|
/// Re-export of the send half of an established WebSocket connection.
|
||||||
|
pub use self::websocket::WebSocketSender;
|
||||||
|
/// Re-export of the WebSocket client connection constructor using baseline defaults.
|
||||||
|
pub use self::websocket::connect;
|
||||||
|
/// Re-export of the configurable WebSocket client connection constructor.
|
||||||
|
pub use self::websocket::connect_with_config;
|
||||||
411
crates/common/game-realtime-websocket-lib/src/websocket.rs
Normal file
411
crates/common/game-realtime-websocket-lib/src/websocket.rs
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/src/websocket.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
use futures_util::SinkExt; // rust-rules: trait-import
|
||||||
|
use futures_util::StreamExt; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::websocket";
|
||||||
|
|
||||||
|
type ClientStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
type ServerStream = tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>;
|
||||||
|
type ClientSink = futures_util::stream::SplitSink<ClientStream, tokio_tungstenite::tungstenite::Message>;
|
||||||
|
type ServerSink = futures_util::stream::SplitSink<ServerStream, tokio_tungstenite::tungstenite::Message>;
|
||||||
|
type ClientReceiver = futures_util::stream::SplitStream<ClientStream>;
|
||||||
|
type ServerReceiver = futures_util::stream::SplitStream<ServerStream>;
|
||||||
|
|
||||||
|
enum WebSocketStreamKind {
|
||||||
|
Client(ClientStream),
|
||||||
|
Server(ServerStream),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebSocketSinkKind {
|
||||||
|
Client(ClientSink),
|
||||||
|
Server(ServerSink),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebSocketReceiverKind {
|
||||||
|
Client(ClientReceiver),
|
||||||
|
Server(ServerReceiver),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Established WebSocket connection implementing the transport-neutral realtime contract.
|
||||||
|
pub struct WebSocketConnection {
|
||||||
|
inner: WebSocketStreamKind,
|
||||||
|
config: crate::WebSocketConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketConnection {
|
||||||
|
fn from_client(stream: ClientStream, config: crate::WebSocketConfig) -> Self {
|
||||||
|
return Self { inner: WebSocketStreamKind::Client(stream), config };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_server(stream: ServerStream, config: crate::WebSocketConfig) -> Self {
|
||||||
|
return Self { inner: WebSocketStreamKind::Server(stream), config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl game_realtime_transport_lib::RealtimeConnection for WebSocketConnection {
|
||||||
|
type Sender = crate::WebSocketSender;
|
||||||
|
type Receiver = crate::WebSocketReceiver;
|
||||||
|
|
||||||
|
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||||
|
let max_frame_size = self.config.max_frame_size();
|
||||||
|
let max_message_size = self.config.max_message_size();
|
||||||
|
let send_timeout = self.config.send_timeout();
|
||||||
|
let close_timeout = self.config.close_timeout();
|
||||||
|
return match self.inner {
|
||||||
|
WebSocketStreamKind::Client(stream) => {
|
||||||
|
let (sender, receiver) = stream.split();
|
||||||
|
(
|
||||||
|
crate::WebSocketSender {
|
||||||
|
inner: WebSocketSinkKind::Client(sender),
|
||||||
|
max_frame_size,
|
||||||
|
max_message_size,
|
||||||
|
send_timeout,
|
||||||
|
close_timeout,
|
||||||
|
send_timed_out: false,
|
||||||
|
},
|
||||||
|
crate::WebSocketReceiver { inner: WebSocketReceiverKind::Client(receiver) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
WebSocketStreamKind::Server(stream) => {
|
||||||
|
let (sender, receiver) = stream.split();
|
||||||
|
(
|
||||||
|
crate::WebSocketSender {
|
||||||
|
inner: WebSocketSinkKind::Server(sender),
|
||||||
|
max_frame_size,
|
||||||
|
max_message_size,
|
||||||
|
send_timeout,
|
||||||
|
close_timeout,
|
||||||
|
send_timed_out: false,
|
||||||
|
},
|
||||||
|
crate::WebSocketReceiver { inner: WebSocketReceiverKind::Server(receiver) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send half of an established WebSocket transport connection.
|
||||||
|
pub struct WebSocketSender {
|
||||||
|
inner: WebSocketSinkKind,
|
||||||
|
max_frame_size: usize,
|
||||||
|
max_message_size: usize,
|
||||||
|
send_timeout: std::time::Duration,
|
||||||
|
close_timeout: std::time::Duration,
|
||||||
|
send_timed_out: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl game_realtime_transport_lib::RealtimeSender for WebSocketSender {
|
||||||
|
type SendFuture<'a>
|
||||||
|
= futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
type CloseFuture<'a>
|
||||||
|
= futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> {
|
||||||
|
return Box::pin(async move {
|
||||||
|
if self.send_timed_out {
|
||||||
|
return Err(game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Aborted,
|
||||||
|
"sender is unavailable after a previous send timeout",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload_len = message.len();
|
||||||
|
if payload_len > self.max_message_size || payload_len > self.max_frame_size {
|
||||||
|
let error = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
|
||||||
|
format!("binary payload size {payload_len} exceeds configured message/frame maxima {}/{}", self.max_message_size, self.max_frame_size),
|
||||||
|
);
|
||||||
|
tracing::warn!(
|
||||||
|
target: TRACING_TARGET,
|
||||||
|
payload_len = payload_len,
|
||||||
|
max_message_size = self.max_message_size,
|
||||||
|
max_frame_size = self.max_frame_size,
|
||||||
|
"outbound WebSocket payload rejected"
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let websocket_message = tokio_tungstenite::tungstenite::Message::Binary(message.into_bytes().into());
|
||||||
|
let send_timeout = self.send_timeout;
|
||||||
|
let send = async {
|
||||||
|
return match &mut self.inner {
|
||||||
|
WebSocketSinkKind::Client(sender) => sender.send(websocket_message).await,
|
||||||
|
WebSocketSinkKind::Server(sender) => sender.send(websocket_message).await,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let result = tokio::time::timeout(send_timeout, send).await;
|
||||||
|
return match result {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "binary WebSocket payload sent");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let mapped = map_stream_error(error);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket send failed");
|
||||||
|
Err(mapped)
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
self.send_timed_out = true;
|
||||||
|
let error = timeout_error("WebSocket send", send_timeout);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(send_timeout), "WebSocket send timed out");
|
||||||
|
Err(error)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) -> Self::CloseFuture<'_> {
|
||||||
|
return Box::pin(async move {
|
||||||
|
let close_timeout = self.close_timeout;
|
||||||
|
let close = async {
|
||||||
|
return match &mut self.inner {
|
||||||
|
WebSocketSinkKind::Client(sender) => sender.close().await,
|
||||||
|
WebSocketSinkKind::Server(sender) => sender.close().await,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let result = tokio::time::timeout(close_timeout, close).await;
|
||||||
|
return match result {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
tracing::debug!(target: TRACING_TARGET, "local WebSocket close initiated");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let mapped = map_stream_error(error);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket close failed");
|
||||||
|
Err(mapped)
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
let error = timeout_error("WebSocket close", close_timeout);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(close_timeout), "WebSocket close timed out");
|
||||||
|
Err(error)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive half of an established WebSocket transport connection.
|
||||||
|
pub struct WebSocketReceiver {
|
||||||
|
inner: WebSocketReceiverKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl game_realtime_transport_lib::RealtimeReceiver for WebSocketReceiver {
|
||||||
|
type ReceiveFuture<'a>
|
||||||
|
= futures_util::future::LocalBoxFuture<'a, Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
|
||||||
|
return Box::pin(async move {
|
||||||
|
loop {
|
||||||
|
let next_message = match &mut self.inner {
|
||||||
|
WebSocketReceiverKind::Client(receiver) => receiver.next().await,
|
||||||
|
WebSocketReceiverKind::Server(receiver) => receiver.next().await,
|
||||||
|
};
|
||||||
|
match next_message {
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(bytes))) => {
|
||||||
|
tracing::trace!(target: TRACING_TARGET, payload_len = bytes.len(), "binary WebSocket payload received");
|
||||||
|
return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(bytes.to_vec())));
|
||||||
|
},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => {
|
||||||
|
tracing::debug!(target: TRACING_TARGET, "remote WebSocket close observed");
|
||||||
|
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
||||||
|
},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) | Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => {},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) => {
|
||||||
|
let error = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||||
|
"text WebSocket messages are not part of the binary transport contract",
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unsupported WebSocket text message received");
|
||||||
|
return Err(error);
|
||||||
|
},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Frame(_))) => {
|
||||||
|
let error = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||||
|
"unexpected raw WebSocket frame surfaced by the backend",
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unexpected raw WebSocket frame received");
|
||||||
|
return Err(error);
|
||||||
|
},
|
||||||
|
Some(Err(error)) => {
|
||||||
|
let mapped = map_stream_error(error);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket receive failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::debug!(target: TRACING_TARGET, "WebSocket stream ended");
|
||||||
|
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound TCP listener that upgrades accepted peers to WebSocket connections.
|
||||||
|
pub struct WebSocketListener {
|
||||||
|
listener: tokio::net::TcpListener,
|
||||||
|
local_addr: std::net::SocketAddr,
|
||||||
|
config: crate::WebSocketConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketListener {
|
||||||
|
/// Binds a WebSocket listener with the baseline configuration.
|
||||||
|
pub async fn bind(address: std::net::SocketAddr) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||||
|
return Self::bind_with_config(address, crate::WebSocketConfig::default()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds a WebSocket listener with explicit product-facing limits and deadlines.
|
||||||
|
pub async fn bind_with_config(address: std::net::SocketAddr, config: crate::WebSocketConfig) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||||
|
if let Err(error) = config.validate() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let listener = match tokio::net::TcpListener::bind(address).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
||||||
|
tracing::warn!(target: TRACING_TARGET, address = %address, detail = mapped.detail(), "WebSocket listener bind failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let local_addr = match listener.local_addr() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
||||||
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebSocket listener address lookup failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebSocket listener bound");
|
||||||
|
return Ok(Self { listener, local_addr, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the concrete local socket address, including an ephemeral port selected by the OS.
|
||||||
|
#[must_use]
|
||||||
|
pub fn local_addr(&self) -> std::net::SocketAddr {
|
||||||
|
return self.local_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts one TCP peer and completes a bounded server-side WebSocket handshake.
|
||||||
|
pub async fn accept(&self) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
|
||||||
|
let (stream, peer_addr) = match self.listener.accept().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
|
||||||
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebSocket TCP accept failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let handshake = tokio_tungstenite::accept_async_with_config(stream, Some(tungstenite_config(&self.config)));
|
||||||
|
let result = tokio::time::timeout(self.config.connect_timeout(), handshake).await;
|
||||||
|
let websocket = match result {
|
||||||
|
Ok(Ok(value)) => value,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
|
||||||
|
tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, detail = mapped.detail(), "WebSocket server handshake failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
let error = timeout_error("WebSocket server handshake", self.config.connect_timeout());
|
||||||
|
tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, timeout_ms = duration_millis(self.config.connect_timeout()), "WebSocket server handshake timed out");
|
||||||
|
return Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, peer = %peer_addr, "WebSocket peer accepted");
|
||||||
|
return Ok(crate::WebSocketConnection::from_server(websocket, self.config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a client to one plain `ws://` endpoint with the baseline configuration.
|
||||||
|
pub async fn connect(endpoint: &str) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
|
||||||
|
return crate::connect_with_config(endpoint, crate::WebSocketConfig::default()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a client to one plain `ws://` endpoint with explicit limits and deadlines.
|
||||||
|
pub async fn connect_with_config(
|
||||||
|
endpoint: &str,
|
||||||
|
config: crate::WebSocketConfig,
|
||||||
|
) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
|
||||||
|
if !endpoint.starts_with("ws://") {
|
||||||
|
return Err(game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration,
|
||||||
|
"the baseline WebSocket backend accepts only ws:// endpoints",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = config.validate() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
tracing::debug!(target: TRACING_TARGET, endpoint = endpoint, "connecting WebSocket client");
|
||||||
|
let handshake = tokio_tungstenite::connect_async_with_config(endpoint, Some(tungstenite_config(&config)), false);
|
||||||
|
let result = tokio::time::timeout(config.connect_timeout(), handshake).await;
|
||||||
|
let (stream, _) = match result {
|
||||||
|
Ok(Ok(value)) => value,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let mapped = map_connect_error(error);
|
||||||
|
tracing::warn!(
|
||||||
|
target: TRACING_TARGET,
|
||||||
|
endpoint = endpoint,
|
||||||
|
kind = %mapped.kind(),
|
||||||
|
detail = mapped.detail(),
|
||||||
|
"WebSocket client connect failed"
|
||||||
|
);
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
let error = timeout_error("WebSocket client connect", config.connect_timeout());
|
||||||
|
tracing::warn!(target: TRACING_TARGET, endpoint = endpoint, timeout_ms = duration_millis(config.connect_timeout()), "WebSocket client connect timed out");
|
||||||
|
return Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, endpoint = endpoint, "WebSocket client connected");
|
||||||
|
return Ok(crate::WebSocketConnection::from_client(stream, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tungstenite_config(config: &crate::WebSocketConfig) -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
|
||||||
|
return tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||||
|
.write_buffer_size(config.write_buffer_size())
|
||||||
|
.max_write_buffer_size(config.max_write_buffer_size())
|
||||||
|
.max_message_size(Some(config.max_message_size()))
|
||||||
|
.max_frame_size(Some(config.max_frame_size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timeout_error(operation: &str, timeout: std::time::Duration) -> game_realtime_transport_lib::TransportError {
|
||||||
|
return game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Timeout,
|
||||||
|
format!("{operation} exceeded configured deadline of {} ms", duration_millis(timeout)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_millis(duration: std::time::Duration) -> u128 {
|
||||||
|
return duration.as_millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_connect_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError {
|
||||||
|
let kind = match &error {
|
||||||
|
tokio_tungstenite::tungstenite::Error::Url(_) => game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration,
|
||||||
|
_ => game_realtime_transport_lib::TransportErrorKind::Connect,
|
||||||
|
};
|
||||||
|
return game_realtime_transport_lib::TransportError::new(kind, error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_stream_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError {
|
||||||
|
let kind = match &error {
|
||||||
|
tokio_tungstenite::tungstenite::Error::ConnectionClosed | tokio_tungstenite::tungstenite::Error::AlreadyClosed => {
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Closed
|
||||||
|
},
|
||||||
|
tokio_tungstenite::tungstenite::Error::Io(_) => game_realtime_transport_lib::TransportErrorKind::Io,
|
||||||
|
tokio_tungstenite::tungstenite::Error::Capacity(_) => game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
|
||||||
|
tokio_tungstenite::tungstenite::Error::WriteBufferFull(_) => game_realtime_transport_lib::TransportErrorKind::Backpressure,
|
||||||
|
_ => game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||||
|
};
|
||||||
|
return game_realtime_transport_lib::TransportError::new(kind, error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/websocket.rs"]
|
||||||
|
mod tests;
|
||||||
62
crates/common/game-realtime-websocket-lib/tests/loopback.rs
Normal file
62
crates/common/game-realtime-websocket-lib/tests/loopback.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/tests/loopback.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Deterministic localhost proof for the Tokio/tokio-tungstenite backend.
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn binary_round_trip_and_clean_close_work_on_loopback() {
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => panic!("loopback listener bind failed: {error}"),
|
||||||
|
};
|
||||||
|
let endpoint = format!("ws://{}/", listener.local_addr());
|
||||||
|
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||||
|
return tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint.as_str()));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (server_connection, client_connection) = match pair {
|
||||||
|
Ok((Ok(server), Ok(client))) => (server, client),
|
||||||
|
Ok((_server, _client)) => panic!("loopback connection establishment failed"),
|
||||||
|
Err(_) => panic!("loopback connection establishment timed out"),
|
||||||
|
};
|
||||||
|
let (mut server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
let (mut client_sender, mut client_receiver) = client_connection.split();
|
||||||
|
let client_payload = game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]);
|
||||||
|
let client_send = tokio::time::timeout(TEST_TIMEOUT, client_sender.send(client_payload)).await;
|
||||||
|
assert!(matches!(client_send, Ok(Ok(()))));
|
||||||
|
let server_receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match server_receive {
|
||||||
|
Ok(Ok(received)) => assert_eq!(
|
||||||
|
received,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]))
|
||||||
|
),
|
||||||
|
Ok(Err(error)) => panic!("server receive failed: {error}"),
|
||||||
|
Err(_) => panic!("server receive timed out"),
|
||||||
|
}
|
||||||
|
let server_payload = game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6]);
|
||||||
|
let server_send = tokio::time::timeout(TEST_TIMEOUT, server_sender.send(server_payload)).await;
|
||||||
|
assert!(matches!(server_send, Ok(Ok(()))));
|
||||||
|
let client_receive = tokio::time::timeout(TEST_TIMEOUT, client_receiver.receive()).await;
|
||||||
|
match client_receive {
|
||||||
|
Ok(Ok(received)) => {
|
||||||
|
assert_eq!(received, game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6])))
|
||||||
|
},
|
||||||
|
Ok(Err(error)) => panic!("client receive failed: {error}"),
|
||||||
|
Err(_) => panic!("client receive timed out"),
|
||||||
|
}
|
||||||
|
let client_close = tokio::time::timeout(TEST_TIMEOUT, client_sender.close()).await;
|
||||||
|
assert!(matches!(client_close, Ok(Ok(()))));
|
||||||
|
let server_close_observation = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match server_close_observation {
|
||||||
|
Ok(Ok(received)) => assert_eq!(received, game_realtime_transport_lib::TransportReceive::Closed),
|
||||||
|
Ok(Err(error)) => panic!("server close observation failed: {error}"),
|
||||||
|
Err(_) => panic!("server close observation timed out"),
|
||||||
|
};
|
||||||
|
}
|
||||||
134
crates/common/game-realtime-websocket-lib/tests/robustness.rs
Normal file
134
crates/common/game-realtime-websocket-lib/tests/robustness.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/tests/robustness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Negative and bounded lifecycle tests for the WebSocket transport backend.
|
||||||
|
|
||||||
|
use futures_util::SinkExt; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const SMALL_MESSAGE_LIMIT: usize = 32;
|
||||||
|
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
|
type RawClient = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn outbound_payload_over_the_configured_limit_is_rejected_before_write() {
|
||||||
|
let config = small_message_config();
|
||||||
|
let (server_connection, client_connection) = establish_backend_pair(config).await;
|
||||||
|
let (_server_sender, _server_receiver) = server_connection.split();
|
||||||
|
let (mut client_sender, _client_receiver) = client_connection.split();
|
||||||
|
let oversized = game_realtime_transport_lib::TransportMessage::new(vec![7; SMALL_MESSAGE_LIMIT + 1]);
|
||||||
|
let result = client_sender.send(oversized).await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => panic!("oversized outbound payload was accepted"),
|
||||||
|
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn inbound_payload_over_the_configured_limit_is_rejected_by_tungstenite() {
|
||||||
|
let config = small_message_config();
|
||||||
|
let (server_connection, mut raw_client) = establish_backend_server_with_raw_client(config).await;
|
||||||
|
let (_server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
let send = raw_client.send(tokio_tungstenite::tungstenite::Message::binary(vec![3; SMALL_MESSAGE_LIMIT + 1])).await;
|
||||||
|
assert!(send.is_ok());
|
||||||
|
let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match receive {
|
||||||
|
Ok(Ok(value)) => panic!("oversized inbound payload produced a successful receive: {value:?}"),
|
||||||
|
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge),
|
||||||
|
Err(_) => panic!("oversized inbound payload did not complete within the test timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn text_message_is_rejected_by_the_binary_transport_contract() {
|
||||||
|
let (server_connection, mut raw_client) = establish_backend_server_with_raw_client(game_realtime_websocket_lib::WebSocketConfig::default()).await;
|
||||||
|
let (_server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
let send = raw_client.send(tokio_tungstenite::tungstenite::Message::text("text is outside the transport contract")).await;
|
||||||
|
assert!(send.is_ok());
|
||||||
|
let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match receive {
|
||||||
|
Ok(Ok(value)) => panic!("text message produced a successful receive: {value:?}"),
|
||||||
|
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol),
|
||||||
|
Err(_) => panic!("text-message rejection did not complete within the test timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn peer_drop_without_close_handshake_is_reported_as_protocol_failure() {
|
||||||
|
let (server_connection, raw_client) = establish_backend_server_with_raw_client(game_realtime_websocket_lib::WebSocketConfig::default()).await;
|
||||||
|
let (_server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
drop(raw_client);
|
||||||
|
let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match receive {
|
||||||
|
Ok(Ok(value)) => panic!("abrupt peer drop was reported as a successful receive: {value:?}"),
|
||||||
|
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol),
|
||||||
|
Err(_) => panic!("abrupt peer drop was not observed within the test timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn silent_tcp_peer_hits_the_server_handshake_deadline() {
|
||||||
|
let config = game_realtime_websocket_lib::WebSocketConfig::default().with_connect_timeout(std::time::Duration::from_millis(50));
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => panic!("bounded listener bind failed: {error}"),
|
||||||
|
};
|
||||||
|
let _silent_peer = match tokio::net::TcpStream::connect(listener.local_addr()).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => panic!("silent TCP peer connection failed: {error}"),
|
||||||
|
};
|
||||||
|
let accept = tokio::time::timeout(TEST_TIMEOUT, listener.accept()).await;
|
||||||
|
match accept {
|
||||||
|
Ok(Ok(_connection)) => panic!("silent TCP peer unexpectedly completed a WebSocket handshake"),
|
||||||
|
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Timeout),
|
||||||
|
Err(_) => panic!("server handshake timeout did not fire within the outer test timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_message_config() -> game_realtime_websocket_lib::WebSocketConfig {
|
||||||
|
return game_realtime_websocket_lib::WebSocketConfig::default().with_max_message_size(SMALL_MESSAGE_LIMIT).with_max_frame_size(SMALL_MESSAGE_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn establish_backend_pair(
|
||||||
|
config: game_realtime_websocket_lib::WebSocketConfig,
|
||||||
|
) -> (game_realtime_websocket_lib::WebSocketConnection, game_realtime_websocket_lib::WebSocketConnection) {
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => panic!("loopback listener bind failed: {error}"),
|
||||||
|
};
|
||||||
|
let endpoint = format!("ws://{}/", listener.local_addr());
|
||||||
|
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||||
|
return tokio::join!(listener.accept(), game_realtime_websocket_lib::connect_with_config(endpoint.as_str(), config));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return match pair {
|
||||||
|
Ok((Ok(server), Ok(client))) => (server, client),
|
||||||
|
Ok((_server, _client)) => panic!("loopback connection establishment failed"),
|
||||||
|
Err(_) => panic!("loopback connection establishment timed out"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn establish_backend_server_with_raw_client(
|
||||||
|
config: game_realtime_websocket_lib::WebSocketConfig,
|
||||||
|
) -> (game_realtime_websocket_lib::WebSocketConnection, RawClient) {
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => panic!("loopback listener bind failed: {error}"),
|
||||||
|
};
|
||||||
|
let endpoint = format!("ws://{}/", listener.local_addr());
|
||||||
|
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||||
|
return tokio::join!(listener.accept(), tokio_tungstenite::connect_async(endpoint.as_str()));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return match pair {
|
||||||
|
Ok((Ok(server), Ok((client, _response)))) => (server, client),
|
||||||
|
Ok((_server, _client)) => panic!("raw-client loopback connection establishment failed"),
|
||||||
|
Err(_) => panic!("raw-client loopback connection establishment timed out"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/unit_tests/config.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_configuration_matches_the_product_baseline() {
|
||||||
|
let config = crate::WebSocketConfig::default();
|
||||||
|
assert_eq!(config.max_message_size(), 1024 * 1024);
|
||||||
|
assert_eq!(config.max_frame_size(), 1024 * 1024);
|
||||||
|
assert_eq!(config.write_buffer_size(), 64 * 1024);
|
||||||
|
assert_eq!(config.max_write_buffer_size(), 2 * 1024 * 1024);
|
||||||
|
assert_eq!(config.connect_timeout(), std::time::Duration::from_secs(10));
|
||||||
|
assert_eq!(config.send_timeout(), std::time::Duration::from_secs(5));
|
||||||
|
assert_eq!(config.close_timeout(), std::time::Duration::from_secs(2));
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_message_and_write_buffer_bounds_are_rejected() {
|
||||||
|
let zero_message = crate::WebSocketConfig::default().with_max_message_size(0);
|
||||||
|
assert_invalid_configuration(zero_message);
|
||||||
|
let oversized_frame = crate::WebSocketConfig::default().with_max_frame_size(2 * 1024 * 1024);
|
||||||
|
assert_invalid_configuration(oversized_frame);
|
||||||
|
let insufficient_write_buffer = crate::WebSocketConfig::default().with_max_write_buffer_size(1024 * 1024);
|
||||||
|
assert_invalid_configuration(insufficient_write_buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_operation_deadlines_are_rejected() {
|
||||||
|
assert_invalid_configuration(crate::WebSocketConfig::default().with_connect_timeout(std::time::Duration::ZERO));
|
||||||
|
assert_invalid_configuration(crate::WebSocketConfig::default().with_send_timeout(std::time::Duration::ZERO));
|
||||||
|
assert_invalid_configuration(crate::WebSocketConfig::default().with_close_timeout(std::time::Duration::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_invalid_configuration(config: crate::WebSocketConfig) {
|
||||||
|
let result = config.validate();
|
||||||
|
match result {
|
||||||
|
Ok(()) => panic!("invalid WebSocket configuration was accepted"),
|
||||||
|
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tungstenite_backpressure_maps_to_transport_backpressure() {
|
||||||
|
let message = tokio_tungstenite::tungstenite::Message::Binary(vec![1, 2, 3].into());
|
||||||
|
let backend_error = tokio_tungstenite::tungstenite::Error::WriteBufferFull(Box::new(message));
|
||||||
|
let mapped = super::map_stream_error(backend_error);
|
||||||
|
assert_eq!(mapped.kind(), game_realtime_transport_lib::TransportErrorKind::Backpressure);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tungstenite_capacity_maps_to_message_too_large() {
|
||||||
|
let capacity = tokio_tungstenite::tungstenite::error::CapacityError::MessageTooLong { size: 65, max_size: 64 };
|
||||||
|
let backend_error = tokio_tungstenite::tungstenite::Error::Capacity(capacity);
|
||||||
|
let mapped = super::map_stream_error(backend_error);
|
||||||
|
assert_eq!(mapped.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge);
|
||||||
|
}
|
||||||
32
crates/common/game-realtime-webtransport-lib/Cargo.toml
Normal file
32
crates/common/game-realtime-webtransport-lib/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# file: crates/common/game-realtime-webtransport-lib/Cargo.toml
|
||||||
|
# version: 4
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-webtransport-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
game-realtime-transport-lib = { path = "../game-realtime-transport-lib" }
|
||||||
|
tracing.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
rcgen = { workspace = true, features = ["ring"] }
|
||||||
|
tokio = { workspace = true, features = ["time"] }
|
||||||
|
web-transport-quinn = { workspace = true, features = ["ring"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
futures-util = { workspace = true, features = ["std"] }
|
||||||
|
gloo-timers = { workspace = true, features = ["futures"] }
|
||||||
|
web-transport-wasm.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
137
crates/common/game-realtime-webtransport-lib/README.md
Normal file
137
crates/common/game-realtime-webtransport-lib/README.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<!-- file: crates/common/game-realtime-webtransport-lib/README.md -->
|
||||||
|
<!-- version: 7 -->
|
||||||
|
|
||||||
|
# game-realtime-webtransport-lib
|
||||||
|
|
||||||
|
Backend WebTransport/QUIC retenu comme second backend realtime de `games.sasedev`, aux côtés de WebSocket qui reste la baseline/fallback de référence.
|
||||||
|
|
||||||
|
## Responsabilité
|
||||||
|
|
||||||
|
La crate possède le transport WebTransport concret sans introduire de sémantique gameplay, room, joueur, tick ou snapshot. Le chemin natif repose sur `web-transport-quinn`; le chemin client `wasm32-unknown-unknown` repose sur `web-transport-wasm` et l'API WebTransport du navigateur. Les deux conservent les erreurs publiques dans `game-realtime-transport-lib`.
|
||||||
|
|
||||||
|
La frontière fiable disponible couvre désormais :
|
||||||
|
|
||||||
|
- configuration client HTTPS avec pin SHA-256 exact ;
|
||||||
|
- identité serveur X.509 DER + clé privée PKCS#8 DER injectables ;
|
||||||
|
- génération locale d'une identité self-signed ECDSA P-256 à validité courte pour `localhost`, IPv4 loopback et IPv6 loopback ;
|
||||||
|
- bind UDP/QUIC sur adresse explicite ou port éphémère ;
|
||||||
|
- établissement HTTP/3 WebTransport client/server natif ;
|
||||||
|
- sélection d'un unique stream bidirectionnel fiable comme chemin realtime principal ;
|
||||||
|
- framing privé `u32` big-endian + payload binaire ;
|
||||||
|
- limite de message configurable, 1 MiB par défaut, vérifiée avant allocation côté réception et avant écriture côté émission ;
|
||||||
|
- deadlines configurables sur les chemins natif et navigateur pour la connexion, l'ouverture/accept du stream primaire et un envoi complet ;
|
||||||
|
- adaptation `RealtimeConnection` / `RealtimeSender` / `RealtimeReceiver` ;
|
||||||
|
- FIN propre via `RealtimeSender::close()` ;
|
||||||
|
- reset/STOP_SENDING backend-spécifiques via `WebTransportSender::abort(...)` et `WebTransportReceiver::abort(...)` ;
|
||||||
|
- cancellation/drop terminale : un sender abandonné est reset plutôt que transformé implicitement en FIN ;
|
||||||
|
- parseur de framing réception incrémental conservant son état si une future `receive()` est annulée ;
|
||||||
|
- mapping stable des erreurs reset/close/session/protocole vers `TransportErrorKind` ;
|
||||||
|
- tracing sous `games::realtime::webtransport`;
|
||||||
|
- client WASM avec endpoint HTTPS, hash certificat SHA-256 explicite, établissement de session navigateur et ouverture du stream bidirectionnel primaire;
|
||||||
|
- adaptation WASM du framing `u32` big-endian et des traits realtime, y compris FIN, reset/STOP et réception incrémentale.
|
||||||
|
- capacité datagram WebTransport backend-spécifique sur les sessions native et navigateur, sans extension de `RealtimeConnection`.
|
||||||
|
|
||||||
|
## Chemin navigateur/WASM
|
||||||
|
|
||||||
|
Pour `wasm32-unknown-unknown`, la crate remplace les dépendances natives Quinn/Tokio/rcgen par `web-transport-wasm`. Le build final reçoit `--cfg=web_sys_unstable_apis` uniquement pour cette cible via `.cargo/config.toml`, conformément à l'exigence actuelle des bindings WebTransport de `web-sys`.
|
||||||
|
|
||||||
|
L'API client garde les mêmes noms de surface que le client natif : `WebTransportCertificateHash`, `WebTransportClientConfig`, `connect`, `WebTransportSession`, `WebTransportConnection`, `WebTransportSender` et `WebTransportReceiver`. Le navigateur ouvre toujours le stream bidirectionnel primaire côté client ; le serveur reste le backend Rust natif.
|
||||||
|
|
||||||
|
Le pin SHA-256 est transmis à `WebTransportOptions.serverCertificateHashes`; aucune variante navigateur sans validation TLS n'est ajoutée. Le framing applicatif reste strictement identique au natif.
|
||||||
|
|
||||||
|
À partir de `alpha.7`, les deadlines `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont aussi matérialisées côté navigateur par des timers WASM. Une deadline navigateur doit tenir dans la plage `u32` millisecondes ; une valeur supérieure est rejetée comme `InvalidConfiguration`. Une réception idle reste volontairement sans timeout implicite, comme sur le chemin natif. Le smoke navigateur technique séparé prouve ensuite cette surface avec un vrai navigateur sans fallback WebSocket.
|
||||||
|
|
||||||
|
## Datagrams backend-spécifiques
|
||||||
|
|
||||||
|
`WebTransportSession` expose `max_datagram_size()`, `send_datagram(...)` et `receive_datagram()` sur les chemins natif et navigateur. Cette surface reste volontairement propre au backend WebTransport : un datagram n'est ni fiable ni ordonné et ne peut donc pas satisfaire le contrat `RealtimeConnection`.
|
||||||
|
|
||||||
|
La taille maximale dépend de la session et du chemin réseau. Le backend rejette localement un payload supérieur à `max_datagram_size()` avec `TransportErrorKind::MessageTooLarge`; cette valeur peut évoluer entre sessions et ne doit pas être traitée comme une constante produit.
|
||||||
|
|
||||||
|
`receive_datagram()` n'impose aucun timeout. Le consommateur doit borner l'attente selon son scénario. Le smoke `game-realtime-webtransport-datagram-smoke` utilise une deadline locale précisément parce que la perte d'un datagram est un résultat autorisé par le protocole. Il vérifie un échange loopback dans les deux sens sans en déduire de garantie de livraison ou d'ordre.
|
||||||
|
|
||||||
|
Le chemin navigateur compile la même capacité via `web-transport-wasm`; `alpha.9` ne réouvre pas le smoke navigateur interactif et limite la preuve runtime datagram au backend natif.
|
||||||
|
|
||||||
|
## TLS de développement
|
||||||
|
|
||||||
|
`WebTransportServerIdentity::generate_loopback()` crée une identité en mémoire. La clé privée n'est ni écrite ni versionnée. Le certificat est valide sept jours, avec une petite marge de clock skew, et son SHA-256 est exposé à travers `WebTransportCertificateHash` afin que le client puisse utiliser le pinning fourni par `web-transport-quinn`.
|
||||||
|
|
||||||
|
Une identité préexistante peut être injectée en DER avec `WebTransportServerIdentity::from_pkcs8_der(...)`. La compatibilité certificat/clé est alors vérifiée par le builder TLS au bind du serveur.
|
||||||
|
|
||||||
|
Aucune option de désactivation globale de la vérification TLS n'est exposée.
|
||||||
|
|
||||||
|
## Stream fiable principal
|
||||||
|
|
||||||
|
Une session WebTransport établie n'est pas encore le contrat realtime lui-même. Le client appelle `WebTransportSession::open_primary_connection()` ; le serveur appelle `WebTransportSession::accept_primary_connection()`.
|
||||||
|
|
||||||
|
Le chemin logique devient ensuite :
|
||||||
|
|
||||||
|
```text
|
||||||
|
one WebTransport session
|
||||||
|
-> one primary bidirectional reliable stream
|
||||||
|
-> u32 big-endian payload length
|
||||||
|
-> payload bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
`open_primary_connection()` écrit déjà l'en-tête de stream WebTransport requis par HTTP/3 avant de retourner. Le pair peut donc terminer `accept_primary_connection()` avant l'envoi de la première frame applicative ; aucun préambule propre à games.sasedev n'est nécessaire.
|
||||||
|
|
||||||
|
`RealtimeConnection::split()` conserve la session WebTransport dans les deux moitiés afin que la session ne soit pas fermée au moment où l'objet connexion est consommé.
|
||||||
|
|
||||||
|
## Limites et deadlines
|
||||||
|
|
||||||
|
`WebTransportConfig::default()` conserve la baseline de 1 MiB par message. La limite peut être réduite ou augmentée tant qu'elle reste strictement positive et représentable dans le champ de longueur `u32` du framing.
|
||||||
|
|
||||||
|
Sur les chemins natif et navigateur, les deadlines configurables couvrent :
|
||||||
|
|
||||||
|
- connexion client et réponse finale à une requête WebTransport déjà surfacée côté serveur ;
|
||||||
|
- ouverture ou accept du stream bidirectionnel principal ;
|
||||||
|
- écriture complète header + payload d'une frame.
|
||||||
|
|
||||||
|
L'attente d'un nouveau pair sur le listener reste volontairement non bornée : un serveur inactif ne doit pas produire périodiquement une erreur uniquement parce qu'aucun client ne se présente.
|
||||||
|
|
||||||
|
QUIC applique sa propre flow-control. Le backend n'ajoute pas une seconde file applicative : si un envoi reste bloqué par flow-control/réseau au-delà de `send_timeout`, l'opération retourne `TransportErrorKind::Timeout` et le stream est reset afin qu'une frame partiellement transmise ne puisse pas être suivie d'une nouvelle frame invalide. Côté navigateur, le timer est porté par `gloo-timers` et la future WebTransport abandonnée reste traitée selon la sémantique cancel-safe du wrapper amont.
|
||||||
|
|
||||||
|
## Lifecycle, abort et cancellation
|
||||||
|
|
||||||
|
`RealtimeSender::close()` reste la fermeture propre de la direction d'émission et produit un FIN. À l'inverse :
|
||||||
|
|
||||||
|
- `WebTransportSender::abort(code)` envoie un `RESET_STREAM` WebTransport ;
|
||||||
|
- `WebTransportReceiver::abort(code)` envoie un `STOP_SENDING` WebTransport ;
|
||||||
|
- dropper un `WebTransportSender` encore actif provoque un reset explicite ;
|
||||||
|
- dropper un `WebTransportReceiver` encore actif provoque un stop explicite ;
|
||||||
|
- annuler une future `send()` en cours provoque également un reset via une garde de cancellation.
|
||||||
|
|
||||||
|
Une erreur terminale de lecture/écriture rend la moitié concernée indisponible pour une réutilisation silencieuse.
|
||||||
|
|
||||||
|
La réception n'utilise plus une lecture exacte monolithique. Le header et le payload sont lus progressivement avec l'API de lecture cancel-safe de Quinn ; `header_read`/`payload_read` restent dans le receiver. Une future `receive()` annulée peut donc être relancée sans perdre les octets déjà consommés ni décaler le framing.
|
||||||
|
|
||||||
|
## Mapping d'erreurs
|
||||||
|
|
||||||
|
Le backend distingue notamment :
|
||||||
|
|
||||||
|
- payload hors limite -> `MessageTooLarge` ;
|
||||||
|
- reset/STOP_SENDING valide -> `Aborted` ;
|
||||||
|
- stream déjà fermé -> `Closed` ;
|
||||||
|
- fermeture de session WebTransport explicite -> `Closed` ;
|
||||||
|
- erreur de session/connexion non classée comme fermeture propre -> `Io` ;
|
||||||
|
- reset/stop invalide ou framing tronqué -> `Protocol` ;
|
||||||
|
- deadline dépassée -> `Timeout` sur les chemins natif et navigateur.
|
||||||
|
|
||||||
|
Une longueur entrante hors limite ou un framing tronqué provoque aussi l'arrêt de la direction de réception afin d'éviter de poursuivre sur un flux désynchronisé.
|
||||||
|
|
||||||
|
## Frontières actuelles
|
||||||
|
|
||||||
|
La crate ne possède toujours pas :
|
||||||
|
|
||||||
|
- d'API datagram transport-neutral ;
|
||||||
|
- de serveur WebTransport WASM ;
|
||||||
|
- de fallback WebSocket dans le backend ;
|
||||||
|
- de sélection dynamique de transport ;
|
||||||
|
- de protocole wire/session ou de synchronisation gameplay.
|
||||||
|
|
||||||
|
Le fallback WebSocket est prouvé au niveau composition par `game-realtime-transport-fallback-smoke`. La caractérisation comparative est portée par `game-realtime-transport-measure` et documentée dans `docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md`; elle ne fait pas partie de la responsabilité de cette crate.
|
||||||
|
|
||||||
|
Le chemin natif s'exécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Les dépendances navigateur sont target-specific et ne sont donc pas tirées par le backend natif. Réciproquement, `web-transport-quinn`, Tokio et rcgen ne sont pas requis pour construire la crate en `wasm32-unknown-unknown`.
|
||||||
|
|
||||||
|
## Portabilité validée en 0.3.5
|
||||||
|
|
||||||
|
La validation de la version couvre Linux natif client/server, le client navigateur/WASM réel et la cross-compilation du backend pour `aarch64-linux-android` avec API 21. Cette dernière preuve est une preuve de compilation, pas un smoke réseau sur appareil Android. Les plateformes Apple et les autres ABI Android ne sont pas déclarées validées par `0.3.5`.
|
||||||
202
crates/common/game-realtime-webtransport-lib/USAGE.md
Normal file
202
crates/common/game-realtime-webtransport-lib/USAGE.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<!-- file: crates/common/game-realtime-webtransport-lib/USAGE.md -->
|
||||||
|
<!-- version: 6 -->
|
||||||
|
|
||||||
|
# Utilisation de game-realtime-webtransport-lib
|
||||||
|
|
||||||
|
Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés par `game-realtime-webtransport-lib`, ainsi que la capacité datagram backend-spécifique. Il ne décrit ni gameplay ni protocole wire métier.
|
||||||
|
|
||||||
|
## Configuration transport
|
||||||
|
|
||||||
|
`WebTransportConfig` porte les limites et deadlines du chemin fiable. La configuration par défaut garde une limite de 1 MiB par message. Les deadlines sont appliquées sur les chemins natif et navigateur pour la connexion, la sélection du stream primaire et chaque envoi complet.
|
||||||
|
|
||||||
|
Exemple de configuration plus stricte :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let transport = game_realtime_webtransport_lib::WebTransportConfig::default()
|
||||||
|
.with_max_message_size(256 * 1024)
|
||||||
|
.with_connect_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.with_primary_stream_timeout(std::time::Duration::from_secs(2))
|
||||||
|
.with_send_timeout(std::time::Duration::from_secs(2));
|
||||||
|
```
|
||||||
|
|
||||||
|
La validation effective se fait lors du bind serveur ou de la connexion client. Une limite nulle, une limite non représentable en `u32` ou une deadline nulle est rejetée comme `InvalidConfiguration`. Côté navigateur, les deadlines doivent également tenir dans la plage `u32` millisecondes imposée par le timer WASM.
|
||||||
|
|
||||||
|
## Serveur natif
|
||||||
|
|
||||||
|
Créer d'abord l'identité TLS et le listener :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let config = game_realtime_webtransport_lib::WebTransportServerConfig::new(
|
||||||
|
std::net::SocketAddr::from(([127, 0, 0, 1], 4433)),
|
||||||
|
identity,
|
||||||
|
)
|
||||||
|
.with_transport_config(transport);
|
||||||
|
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(config) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let session = match listener.accept().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let connection = match session.accept_primary_connection().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
L'attente du prochain client dans `listener.accept()` n'a pas de timeout périodique. Une fois une requête WebTransport surfacée, sa réponse finale utilise la deadline de connexion configurée.
|
||||||
|
|
||||||
|
`open_primary_connection()` écrit l'en-tête WebTransport requis pour identifier le stream avant de retourner. Le serveur peut donc attendre `accept_primary_connection()` puis commencer les échanges applicatifs ; aucune frame artificielle n'est nécessaire pour rendre le stream visible.
|
||||||
|
|
||||||
|
## Client natif
|
||||||
|
|
||||||
|
Le client doit connaître le SHA-256 exact du certificat serveur :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(
|
||||||
|
"https://127.0.0.1:4433/game",
|
||||||
|
certificate_hash,
|
||||||
|
) {
|
||||||
|
Ok(value) => value.with_transport_config(transport),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let session = match game_realtime_webtransport_lib::connect(&config).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let connection = match session.open_primary_connection().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le pinning est obligatoire dans cette API native ; il n'existe pas de variante qui désactive globalement la vérification TLS.
|
||||||
|
|
||||||
|
## Client navigateur/WASM
|
||||||
|
|
||||||
|
Pour `wasm32-unknown-unknown`, les mêmes noms `WebTransportCertificateHash`, `WebTransportClientConfig`, `connect` et `WebTransportSession::open_primary_connection()` sont disponibles. Le serveur reste natif ; le navigateur est uniquement client.
|
||||||
|
|
||||||
|
Exemple de séquence Rust côté WASM :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let certificate_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256(server_sha256);
|
||||||
|
let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(
|
||||||
|
"https://127.0.0.1:4433/game",
|
||||||
|
certificate_hash,
|
||||||
|
) {
|
||||||
|
Ok(value) => value.with_transport_config(transport),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let session = match game_realtime_webtransport_lib::connect(&config).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let connection = match session.open_primary_connection().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build workspace fournit `web_sys_unstable_apis` uniquement à `wasm32-unknown-unknown`. Le hash SHA-256 est transmis au navigateur comme `serverCertificateHashes`. La limite de message configurée est appliquée au framing WASM. `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont réalisés par des timers WASM ; une expiration retourne `TransportErrorKind::Timeout`, et un send expiré reset le stream comme sur le chemin natif.
|
||||||
|
|
||||||
|
Le host Vite et l'adapter `wasm-bindgen` de `game-realtime-webtransport-browser-smoke` servent uniquement de preuve runtime. Ils ne sont pas nécessaires à un consommateur qui intègre déjà la bibliothèque dans son propre frontend.
|
||||||
|
|
||||||
|
## Cross-compilation Android ARM64
|
||||||
|
|
||||||
|
`0.3.5` a validé la cross-compilation de la crate pour `aarch64-linux-android` avec le NDK `28.2.13676358` et `minSdk`/API 21. Le NDK moderne expose un Clang dont le nom contient le niveau API ; `ring`/`cc-rs` doit recevoir explicitement ce compilateur si le wrapper générique `aarch64-linux-android-clang` n'est pas présent dans le `PATH`.
|
||||||
|
|
||||||
|
Configuration de validation portable à partir du SDK déjà déclaré dans l'environnement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANDROID_SDK="${ANDROID_HOME:-$ANDROID_SDK_ROOT}"
|
||||||
|
export ANDROID_NDK_HOME="$ANDROID_SDK/ndk/28.2.13676358"
|
||||||
|
export TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64"
|
||||||
|
|
||||||
|
export CC_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android21-clang"
|
||||||
|
export CXX_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android21-clang++"
|
||||||
|
export AR_aarch64_linux_android="$TOOLCHAIN/bin/llvm-ar"
|
||||||
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/aarch64-linux-android21-clang"
|
||||||
|
|
||||||
|
cargo check -p game-realtime-webtransport-lib --target aarch64-linux-android
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces variables décrivent une commande de cross-compilation et ne doivent pas être remplacées dans le dépôt par un chemin absolu propre à une machine. Cette preuve ne construit pas d'APK/AAB et ne remplace pas un futur smoke WebTransport sur appareil Android si cette cible devient un chemin produit réel.
|
||||||
|
|
||||||
|
## Contrat realtime
|
||||||
|
|
||||||
|
Une fois le stream primaire sélectionné, utiliser les traits de `game-realtime-transport-lib` pour le chemin fiable normal :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (mut sender, mut receiver) = game_realtime_transport_lib::RealtimeConnection::split(connection);
|
||||||
|
|
||||||
|
let message = game_realtime_transport_lib::TransportMessage::new(vec![1, 2, 3, 4]);
|
||||||
|
if let Err(error) = game_realtime_transport_lib::RealtimeSender::send(&mut sender, message).await {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let received = match game_realtime_transport_lib::RealtimeReceiver::receive(&mut receiver).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut sender).await {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend encode chaque `TransportMessage` sous la forme `u32` big-endian + payload. Le consommateur ne doit pas reproduire ce framing lui-même.
|
||||||
|
|
||||||
|
La flow-control QUIC est respectée naturellement par l'écriture asynchrone. Sur les chemins natif et navigateur, un send qui dépasse sa deadline est considéré terminal : le stream est reset et le même sender ne doit pas être réutilisé.
|
||||||
|
|
||||||
|
## Fermeture et abort
|
||||||
|
|
||||||
|
`RealtimeSender::close()` termine proprement la direction d'émission du stream primaire. Le pair observe ensuite `TransportReceive::Closed` lorsqu'il atteint le FIN après les messages déjà écrits.
|
||||||
|
|
||||||
|
Pour abandonner explicitement une direction WebTransport :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Err(error) = sender.abort(42) {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ou côté réception :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Err(error) = receiver.abort(43) {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces deux méthodes sont backend-spécifiques : elles ne sont pas ajoutées au contrat commun car WebSocket n'expose pas la même primitive QUIC de reset/stop.
|
||||||
|
|
||||||
|
Dropper un sender actif ou annuler une future `send()` en cours provoque un reset explicite. Dropper un receiver actif provoque un stop explicite. Cela évite qu'une cancellation d'écriture partielle soit interprétée comme une fermeture propre ou qu'une frame suivante reprenne au mauvais offset.
|
||||||
|
|
||||||
|
Une future `receive()` peut en revanche être annulée puis relancée : le backend conserve l'état partiel du header/payload et reprend le framing à l'octet correct.
|
||||||
|
|
||||||
|
## Datagrams WebTransport
|
||||||
|
|
||||||
|
Les datagrams sont une capacité propre à `WebTransportSession`; ils ne passent pas par `RealtimeConnection`. Toujours consulter la taille courante avant émission :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let max_payload = session.max_datagram_size();
|
||||||
|
if payload.len() <= max_payload {
|
||||||
|
session.send_datagram(payload)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La réception est asynchrone :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let payload = session.receive_datagram().await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces appels ne donnent aucune garantie de livraison ni d'ordre. Une perte n'est pas une violation du transport datagram. Toute deadline de réception appartient donc au scénario consommateur ; elle n'est pas injectée dans `WebTransportConfig`.
|
||||||
|
|
||||||
|
Le chemin navigateur expose les mêmes noms, avec `send_datagram(...).await`. La surface reste backend-spécifique afin de ne pas imposer une sémantique non fiable à WebSocket ou au contrat fiable commun.
|
||||||
|
|
||||||
109
crates/common/game-realtime-webtransport-lib/src/config.rs
Normal file
109
crates/common/game-realtime-webtransport-lib/src/config.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// file: crates/common/game-realtime-webtransport-lib/src/config.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const DEFAULT_MAX_MESSAGE_SIZE: usize = 1024 * 1024;
|
||||||
|
const DEFAULT_PRIMARY_STREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const DEFAULT_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Product-facing limits and operation deadlines for the WebTransport reliable path.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct WebTransportConfig {
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
max_message_size: usize,
|
||||||
|
primary_stream_timeout: std::time::Duration,
|
||||||
|
send_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebTransportConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self {
|
||||||
|
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
|
||||||
|
primary_stream_timeout: DEFAULT_PRIMARY_STREAM_TIMEOUT,
|
||||||
|
send_timeout: DEFAULT_SEND_TIMEOUT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebTransportConfig {
|
||||||
|
/// Returns a copy with a different client/session handshake deadline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_connect_timeout(mut self, value: std::time::Duration) -> Self {
|
||||||
|
self.connect_timeout = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different maximum framed binary message size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_max_message_size(mut self, value: usize) -> Self {
|
||||||
|
self.max_message_size = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different deadline for opening or accepting the primary stream.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_primary_stream_timeout(mut self, value: std::time::Duration) -> Self {
|
||||||
|
self.primary_stream_timeout = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with a different deadline for one complete framed send operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_send_timeout(mut self, value: std::time::Duration) -> Self {
|
||||||
|
self.send_timeout = value;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured client/session handshake deadline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn connect_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.connect_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum framed binary message size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_message_size(&self) -> usize {
|
||||||
|
return self.max_message_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deadline for opening or accepting the primary stream.
|
||||||
|
#[must_use]
|
||||||
|
pub fn primary_stream_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.primary_stream_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deadline for one complete framed send operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn send_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.send_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates all limits and deadlines required by the reliable WebTransport path.
|
||||||
|
pub fn validate(&self) -> Result<(), game_realtime_transport_lib::TransportError> {
|
||||||
|
if self.max_message_size == 0 {
|
||||||
|
return Err(invalid_configuration("max_message_size must be greater than zero"));
|
||||||
|
}
|
||||||
|
if u32::try_from(self.max_message_size).is_err() {
|
||||||
|
return Err(invalid_configuration("max_message_size must fit the u32 framing length field"));
|
||||||
|
}
|
||||||
|
if self.connect_timeout.is_zero() {
|
||||||
|
return Err(invalid_configuration("connect_timeout must be greater than zero"));
|
||||||
|
}
|
||||||
|
if self.primary_stream_timeout.is_zero() {
|
||||||
|
return Err(invalid_configuration("primary_stream_timeout must be greater than zero"));
|
||||||
|
}
|
||||||
|
if self.send_timeout.is_zero() {
|
||||||
|
return Err(invalid_configuration("send_timeout must be greater than zero"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_configuration(detail: &str) -> game_realtime_transport_lib::TransportError {
|
||||||
|
return game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/config.rs"]
|
||||||
|
mod tests;
|
||||||
68
crates/common/game-realtime-webtransport-lib/src/lib.rs
Normal file
68
crates/common/game-realtime-webtransport-lib/src/lib.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// file: crates/common/game-realtime-webtransport-lib/src/lib.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! WebTransport/QUIC backend candidate for the transport-neutral realtime contract.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod webtransport;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod webtransport_wasm;
|
||||||
|
|
||||||
|
/// Re-export of product-facing limits and operation deadlines for the reliable WebTransport path.
|
||||||
|
pub use self::config::WebTransportConfig;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of the pinned SHA-256 certificate fingerprint used by the native client.
|
||||||
|
pub use self::webtransport::WebTransportCertificateHash;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of native WebTransport client configuration.
|
||||||
|
pub use self::webtransport::WebTransportClientConfig;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of an established WebTransport connection adapted to the transport-neutral realtime contract.
|
||||||
|
pub use self::webtransport::WebTransportConnection;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of the bound native WebTransport listener.
|
||||||
|
pub use self::webtransport::WebTransportListener;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of the receive half of the primary reliable WebTransport stream.
|
||||||
|
pub use self::webtransport::WebTransportReceiver;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of the send half of the primary reliable WebTransport stream.
|
||||||
|
pub use self::webtransport::WebTransportSender;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of native WebTransport server configuration.
|
||||||
|
pub use self::webtransport::WebTransportServerConfig;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of native WebTransport server TLS identity material.
|
||||||
|
pub use self::webtransport::WebTransportServerIdentity;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of an established native WebTransport session.
|
||||||
|
pub use self::webtransport::WebTransportSession;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
/// Re-export of the native WebTransport client establishment function.
|
||||||
|
pub use self::webtransport::connect;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of the pinned SHA-256 certificate fingerprint used by the browser WebTransport client.
|
||||||
|
pub use self::webtransport_wasm::WebTransportCertificateHash;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of browser WebTransport client configuration.
|
||||||
|
pub use self::webtransport_wasm::WebTransportClientConfig;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of an established browser WebTransport connection adapted to the transport-neutral realtime contract.
|
||||||
|
pub use self::webtransport_wasm::WebTransportConnection;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of the receive half of the browser primary reliable WebTransport stream.
|
||||||
|
pub use self::webtransport_wasm::WebTransportReceiver;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of the send half of the browser primary reliable WebTransport stream.
|
||||||
|
pub use self::webtransport_wasm::WebTransportSender;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of an established browser WebTransport session.
|
||||||
|
pub use self::webtransport_wasm::WebTransportSession;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
/// Re-export of the browser WebTransport client establishment function.
|
||||||
|
pub use self::webtransport_wasm::connect;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user