From dfb71a1c7bdb5ad8da8d3cf60d4403b0072ee490 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Sat, 11 Apr 2026 18:17:14 +0100 Subject: [PATCH 01/19] Initial interfaces --- ...nterface-development-interfaces.drawio.svg | 3472 ++++++++++++++++- docs/plans/phase-1-interface-development.md | 398 +- scripts/check-doc-links.ps1 | 80 - .../GameEngineAdapter.UnitTests.csproj | 1 + .../GameEngineAdapter.csproj | 21 +- src/GameEngineAdapter/Program.cs | 1 - 6 files changed, 3805 insertions(+), 168 deletions(-) delete mode 100644 scripts/check-doc-links.ps1 delete mode 100644 src/GameEngineAdapter/Program.cs diff --git a/docs/plans/phase-1-interface-development-interfaces.drawio.svg b/docs/plans/phase-1-interface-development-interfaces.drawio.svg index 4eaa3c0..02f3fa5 100644 --- a/docs/plans/phase-1-interface-development-interfaces.drawio.svg +++ b/docs/plans/phase-1-interface-development-interfaces.drawio.svg @@ -1 +1,3471 @@ -
Engine-specific ECS implementations / wrappers
Engine-specific ECS implementations / wrappers
Engine-specific implementations / wrappers
Engine-specific implementations / wrappers
 ECS Core
 ECS Core
 EngineAdapter Core
 EngineAdapter Core
IEngineAdapter
IEngineAdapter
Capabilities
Capabilities
EngineCapabilities
EngineCapabilities
Is
Is
InputProvider
InputProvider
IInputProvider
IInputProvider
IUserInterfaceProvider
IUserInterfaceProvider
Is
Is
Is
Is
UserInterfaceProvider
UserInterfaceProvider
Is
Is
AssetProvider
AssetProvider
IAssetProvider
IAssetProvider
Is
Is
RenderProvider
RenderProvider
IRenderProvider
IRenderProvider
Derives
Derives
HeadlessEngineCapabilities
HeadlessEngineCapabilities
Implements
Implements
HeadlessInputProvider
HeadlessInputProvider
Implements
Implements
HeadlessUserInterfaceProvider
HeadlessUserInterfaceProvider
Implements
Implements
HeadlessAssetProvider
HeadlessAssetProvider
Implements
Implements
HeadlessRenderProvider
HeadlessRenderProvider
TuiEngineCapabilities
TuiEngineCapabilities
TuiInputProvider
TuiInputProvider
TuiUserInterfaceProvider
TuiUserInterfaceProvider
TuiAssetProvider
TuiAssetProvider
TuiRenderProvider
TuiRenderProvider
Monogame2DEngineCapabilities
Monogame2DEngineCapabilities
Monogame2DInputProvider
Monogame2DInputProvider
Monogame2dUIProvider
Monogame2dUIProvider
Monogame2DAssetProvider
Monogame2DAssetProvider
Monogame2DRenderProvider
Monogame2DRenderProvider
Monogame3DEngineCapabilities
Monogame3DEngineCapabilities
Monogame3DInputProvider
Monogame3DInputProvider
Monogame3dUIProvider
Monogame3dUIProvider
Monogame3DAssetProvider
Monogame3DAssetProvider
Monogame3DRenderProvider
Monogame3DRenderProvider
Is
Is
RootScene
RootScene
Raylib2DEngineCapabilities
Raylib2DEngineCapabilities
Raylib2DInputProvider
Raylib2DInputProvider
Raylib2DUserInterfaceProvider
Raylib2DUserInterfaceProvider
Raylib2DAssetProvider
Raylib2DAssetProvider
Raylib2DRenderProvider
Raylib2DRenderProvider
Raylib3DEngineCapabilities
Raylib3DEngineCapabilities
Raylib3DInputProvider
Raylib3DInputProvider
Raylib3DUserInterfaceProvider
Raylib3DUserInterfaceProvider
Raylib3DAssetProvider
Raylib3DAssetProvider
Raylib3DRenderProvider
Raylib3DRenderProvider
Stride3DEngineCapabilities
Stride3DEngineCapabilities
Stride3DInputProvider
Stride3DInputProvider
Stride3DUserInterfaceProvider
Stride3DUserInterfaceProvider
Stride3DAssetProvider
Stride3DAssetProvider
Stride3DRenderProvider
Stride3DRenderProvider
Entity
Entity
Identity
Identity
ChildEntities
ChildEntities
IComponent
IComponent
Identity
Identity
ITransformComponent
ITransformComponent
Transform
Transform
Is
Is
Components[]
Components[]
ISpriteComponent
ISpriteComponent
SpriteSourceDto
SpriteSourceDto
IMeshComponent
IMeshComponent
MeshSourceDto
MeshSourceDto
IAudioComponent
IAudioComponent
AudioSourceDto
AudioSourceDto
MeshDrawDto
MeshDrawDto
SpriteDrawDto
SpriteDrawDto
AudioPlayDto
AudioPlayDto
Implements
Implements
Implements
Implements
HeadlessEngineAdapter
HeadlessEngineAdapter
TuiEngineAdapter
TuiEngineAdapter
Monogame2DEngineAdapter
Monogame2DEngineAdapter
Monogame3DEngineAdapter
Monogame3DEngineAdapter
Raylib2DEngineAdapter
Raylib2DEngineAdapter
Raylib3DEngineAdapter
Raylib3DEngineAdapter
Stride3DEngineAdapter
Stride3DEngineAdapter
Derives
Derives
Scene
Scene
Is
Is
Implements
Implements
HeadlessSpriteComponent
HeadlessSpriteComponent
TuiSpriteComponent
TuiSpriteComponent
Monogame2DSpriteComponent
Monogame2DSpriteComponent
Monogame3DSpriteComponent
Monogame3DSpriteComponent
Raylib2DSpriteComponent
Raylib2DSpriteComponent
Raylib3DSpriteComponent
Raylib3DSpriteComponent
Stride3DSpriteComponent
Stride3DSpriteComponent
HeadlessSpriteSource
HeadlessSpriteSource
TuiSpriteSource
TuiSpriteSource
Monogame2DSpriteSource
Monogame2DSpriteSource
Monogame3DSpriteSource
Monogame3DSpriteSource
Raylib2DSpriteSource
Raylib2DSpriteSource
Raylib3DSpriteSource
Raylib3DSpriteSource
Stride3DSpriteSource
Stride3DSpriteSource
Implements
Implements
HeadlessMeshComponent
HeadlessMeshComponent
Monogame3DMeshComponent
Monogame3DMeshComponent
Raylib3DMeshComponent
Raylib3DMeshComponent
Stride3DMeshComponent
Stride3DMeshComponent
HeadlessSpriteSource
HeadlessSpriteSource
Monogame3DSpriteSource
Monogame3DSpriteSource
Raylib3DMeshSource
Raylib3DMeshSource
Stride3DMeshSource
Stride3DMeshSource
Implements
Implements
HeadlessAudioComponent
HeadlessAudioComponent
TuiAudioComponent
TuiAudioComponent
Monogame2DAudioComponent
Monogame2DAudioComponent
Monogame3DAudioComponent
Monogame3DAudioComponent
Raylib2DAudioComponent
Raylib2DAudioComponent
Raylib3DAudioComponent
Raylib3DAudioComponent
Stride3DAudioComponent
Stride3DAudioComponent
HeadlessAudioSource
HeadlessAudioSource
TuiAudioSource
TuiAudioSource
Monogame2DAudioSource
Monogame2DAudioSource
Monogame3DAudioSource
Monogame3DAudioSource
Raylib2DAudioSource
Raylib2DAudioSource
Raylib3DAudioSource
Raylib3DAudioSource
Stride3DAudioSource
Stride3DAudioSource
NO-OP or logging implementations
NO-OP or logging imp...
2D-only ASCII-art implementations
2D-only ASCII-art im...
Do 2D and 3D actually need different implementations?
Do 2D and 3D actually need different implementations?
Do 2D and 3D actually need different implementations?
Do 2D and 3D actually need different implementations?
ICameraComponent
ICameraComponent
CameraDescriptor
CameraDescriptor
Stride already has an ECS implementation
Stride already has a...
Implements
Implements
HeadlessCameraComponent
HeadlessCameraComponent
TuiCameraComponent
TuiCameraComponent
MonogameCameraComponent
MonogameCameraComponent
Monogame3DCameraComponent
Monogame3DCameraComponent
Raylib2DCameraComponent
Raylib2DCameraComponent
Raylib3DCameraComponent
Raylib3DCameraComponent
Stride3DCameraComponent
Stride3DCameraComponent
HeadlessCameraDescriptor
HeadlessCameraDescriptor
TuiCameraDescriptor
TuiCameraDescriptor
Monogame2DCameraDescriptor
Monogame2DCameraDescriptor
Monogame3DCameraDescriptor
Monogame3DCameraDescriptor
Raylib2DCameraDescriptor
Raylib2DCameraDescriptor
Raylib3DCameraDescriptor
Raylib3DCameraDescriptor
Stride3DCameraComponent
Stride3DCameraComponent
Implements
Implements
HeadlessTransformComponent
HeadlessTransformComponent
TuiTransformComponent
TuiTransformComponent
Monogame2DTransformCom...
Monogame2DTransformCom...
Monogame3DTransformCom...
Monogame3DTransformCom...
Raylib2DTransformComponent
Raylib2DTransformComponent
Raylib3DTransformComponent
Raylib3DTransformComponent
Stride3DTransformComponent
Stride3DTransformComponent
Text is not SVG - cannot display
\ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ Engine-specific ECS implementations / wrappers +
+
+
+
+ + Engine-specific ECS implementations / wrappers + +
+
+
+ + + + + + + +
+
+
+ Engine-specific implementations / wrappers +
+
+
+
+ + Engine-specific implementations / wrappers + +
+
+
+ + + + + + + +
+
+
+ ECS Core +
+
+
+
+ + ECS Core + +
+
+
+ + + + + + + +
+
+
+ EngineAdapter Core +
+
+
+
+ + EngineAdapter Core + +
+
+
+ + + + + + + +
+
+
+ IEngineAdapter +
+
+
+
+ + IEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ Capabilities +
+
+
+
+ + Capabilities + +
+
+
+ + + + + + + +
+
+
+ EngineCapabilities +
+
+
+
+ + EngineCapabilities + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + +
+
+
+ InputProvider +
+
+
+
+ + InputProvider + +
+
+
+ + + + + + + +
+
+
+ IInputProvider +
+
+
+
+ + IInputProvider + +
+
+
+ + + + + + + +
+
+
+ IUserInterfaceProvider +
+
+
+
+ + IUserInterfaceProvider + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + +
+
+
+ UserInterfaceProvider +
+
+
+
+ + UserInterfaceProvider + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + +
+
+
+ AssetProvider +
+
+
+
+ + AssetProvider + +
+
+
+ + + + + + + +
+
+
+ IAssetProvider +
+
+
+
+ + IAssetProvider + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + +
+
+
+ RenderProvider +
+
+
+
+ + RenderProvider + +
+
+
+ + + + + + + +
+
+
+ IRenderProvider +
+
+
+
+ + IRenderProvider + +
+
+
+ + + + + + + + +
+
+
+ Derives +
+
+
+
+ + Derives + +
+
+
+ + + + + + + +
+
+
+ HeadlessEngineCapabilities +
+
+
+
+ + HeadlessEngineCapabilities + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessInputProvider +
+
+
+
+ + HeadlessInputProvider + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessUserInterfaceProvider +
+
+
+
+ + HeadlessUserInterfaceProvider + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessAssetProvider +
+
+
+
+ + HeadlessAssetProvider + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessRenderProvider +
+
+
+
+ + HeadlessRenderProvider + +
+
+
+ + + + + + + +
+
+
+ TuiEngineCapabilities +
+
+
+
+ + TuiEngineCapabilities + +
+
+
+ + + + + + + +
+
+
+ TuiInputProvider +
+
+
+
+ + TuiInputProvider + +
+
+
+ + + + + + + +
+
+
+ TuiUserInterfaceProvider +
+
+
+
+ + TuiUserInterfaceProvider + +
+
+
+ + + + + + + +
+
+
+ TuiAssetProvider +
+
+
+
+ + TuiAssetProvider + +
+
+
+ + + + + + + +
+
+
+ TuiRenderProvider +
+
+
+
+ + TuiRenderProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame2DEngineCapabilities +
+
+
+
+ + Monogame2DEngineCapabilities + +
+
+
+ + + + + + + +
+
+
+ Monogame2DInputProvider +
+
+
+
+ + Monogame2DInputProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame2dUIProvider +
+
+
+
+ + Monogame2dUIProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame2DAssetProvider +
+
+
+
+ + Monogame2DAssetProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame2DRenderProvider +
+
+
+
+ + Monogame2DRenderProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame3DEngineCapabilities +
+
+
+
+ + Monogame3DEngineCapabilities + +
+
+
+ + + + + + + +
+
+
+ Monogame3DInputProvider +
+
+
+
+ + Monogame3DInputProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame3dUIProvider +
+
+
+
+ + Monogame3dUIProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame3DAssetProvider +
+
+
+
+ + Monogame3DAssetProvider + +
+
+
+ + + + + + + +
+
+
+ Monogame3DRenderProvider +
+
+
+
+ + Monogame3DRenderProvider + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + +
+
+
+ RootScene +
+
+
+
+ + RootScene + +
+
+
+ + + + + + + +
+
+
+ Raylib2DEngineCapabilities +
+
+
+
+ + Raylib2DEngineCapabilities + +
+
+
+ + + + + + + +
+
+
+ Raylib2DInputProvider +
+
+
+
+ + Raylib2DInputProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib2DUserInterfaceProvider +
+
+
+
+ + Raylib2DUserInterfaceProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib2DAssetProvider +
+
+
+
+ + Raylib2DAssetProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib2DRenderProvider +
+
+
+
+ + Raylib2DRenderProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib3DEngineCapabilities +
+
+
+
+ + Raylib3DEngineCapabilities + +
+
+
+ + + + + + + +
+
+
+ Raylib3DInputProvider +
+
+
+
+ + Raylib3DInputProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib3DUserInterfaceProvider +
+
+
+
+ + Raylib3DUserInterfaceProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib3DAssetProvider +
+
+
+
+ + Raylib3DAssetProvider + +
+
+
+ + + + + + + +
+
+
+ Raylib3DRenderProvider +
+
+
+
+ + Raylib3DRenderProvider + +
+
+
+ + + + + + + +
+
+
+ Stride3DEngineCapabilities +
+
+
+
+ + Stride3DEngineCapabilities + +
+
+
+ + + + + + + +
+
+
+ Stride3DInputProvider +
+
+
+
+ + Stride3DInputProvider + +
+
+
+ + + + + + + +
+
+
+ Stride3DUserInterfaceProvider +
+
+
+
+ + Stride3DUserInterfaceProvider + +
+
+
+ + + + + + + +
+
+
+ Stride3DAssetProvider +
+
+
+
+ + Stride3DAssetProvider + +
+
+
+ + + + + + + +
+
+
+ Stride3DRenderProvider +
+
+
+
+ + Stride3DRenderProvider + +
+
+
+ + + + + + + +
+
+
+ Entity +
+
+
+
+ + Entity + +
+
+
+ + + + + + + +
+
+
+ Identity +
+
+
+
+ + Identity + +
+
+
+ + + + + + + +
+
+
+ ChildEntities +
+
+
+
+ + ChildEntities + +
+
+
+ + + + + + + +
+
+
+ IComponent +
+
+
+
+ + IComponent + +
+
+
+ + + + + + + +
+
+
+ Identity +
+
+
+
+ + Identity + +
+
+
+ + + + + + + + + + + +
+
+
+ ITransformComponent +
+
+
+
+ + ITransformComponent + +
+
+
+ + + + + + + +
+
+
+ Transform +
+
+
+
+ + Transform + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + +
+
+
+ Components[] +
+
+
+
+ + Components[] + +
+
+
+ + + + + + + + + + + +
+
+
+ ISpriteComponent +
+
+
+
+ + ISpriteComponent + +
+
+
+ + + + + + + +
+
+
+ SpriteSourceDto +
+
+
+
+ + SpriteSourceDto + +
+
+
+ + + + + + + + + + + +
+
+
+ IMeshComponent +
+
+
+
+ + IMeshComponent + +
+
+
+ + + + + + + +
+
+
+ MeshSourceDto +
+
+
+
+ + MeshSourceDto + +
+
+
+ + + + + + + + + + + +
+
+
+ IAudioComponent +
+
+
+
+ + IAudioComponent + +
+
+
+ + + + + + + +
+
+
+ AudioSourceDto +
+
+
+
+ + AudioSourceDto + +
+
+
+ + + + + + + +
+
+
+ MeshDrawDto +
+
+
+
+ + MeshDrawDto + +
+
+
+ + + + + + + +
+
+
+ SpriteDrawDto +
+
+
+
+ + SpriteDrawDto + +
+
+
+ + + + + + + +
+
+
+ AudioPlayDto +
+
+
+
+ + AudioPlayDto + +
+
+
+ + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessEngineAdapter +
+
+
+
+ + HeadlessEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ TuiEngineAdapter +
+
+
+
+ + TuiEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ Monogame2DEngineAdapter +
+
+
+
+ + Monogame2DEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ Monogame3DEngineAdapter +
+
+
+
+ + Monogame3DEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ Raylib2DEngineAdapter +
+
+
+
+ + Raylib2DEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ Raylib3DEngineAdapter +
+
+
+
+ + Raylib3DEngineAdapter + +
+
+
+ + + + + + + +
+
+
+ Stride3DEngineAdapter +
+
+
+
+ + Stride3DEngineAdapter + +
+
+
+ + + + + + + + +
+
+
+ Derives +
+
+
+
+ + Derives + +
+
+
+ + + + + + + +
+
+
+ Scene +
+
+
+
+ + Scene + +
+
+
+ + + + + + + + +
+
+
+ Is +
+
+
+
+ + Is + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessSpriteComponent +
+
+
+
+ + HeadlessSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ TuiSpriteComponent +
+
+
+
+ + TuiSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame2DSpriteComponent +
+
+
+
+ + Monogame2DSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame3DSpriteComponent +
+
+
+
+ + Monogame3DSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib2DSpriteComponent +
+
+
+
+ + Raylib2DSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib3DSpriteComponent +
+
+
+
+ + Raylib3DSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ Stride3DSpriteComponent +
+
+
+
+ + Stride3DSpriteComponent + +
+
+
+ + + + + + + +
+
+
+ HeadlessSpriteSource +
+
+
+
+ + HeadlessSpriteSource + +
+
+
+ + + + + + + +
+
+
+ TuiSpriteSource +
+
+
+
+ + TuiSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Monogame2DSpriteSource +
+
+
+
+ + Monogame2DSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Monogame3DSpriteSource +
+
+
+
+ + Monogame3DSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Raylib2DSpriteSource +
+
+
+
+ + Raylib2DSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Raylib3DSpriteSource +
+
+
+
+ + Raylib3DSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Stride3DSpriteSource +
+
+
+
+ + Stride3DSpriteSource + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessMeshComponent +
+
+
+
+ + HeadlessMeshComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame3DMeshComponent +
+
+
+
+ + Monogame3DMeshComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib3DMeshComponent +
+
+
+
+ + Raylib3DMeshComponent + +
+
+
+ + + + + + + +
+
+
+ Stride3DMeshComponent +
+
+
+
+ + Stride3DMeshComponent + +
+
+
+ + + + + + + +
+
+
+ HeadlessSpriteSource +
+
+
+
+ + HeadlessSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Monogame3DSpriteSource +
+
+
+
+ + Monogame3DSpriteSource + +
+
+
+ + + + + + + +
+
+
+ Raylib3DMeshSource +
+
+
+
+ + Raylib3DMeshSource + +
+
+
+ + + + + + + +
+
+
+ Stride3DMeshSource +
+
+
+
+ + Stride3DMeshSource + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessAudioComponent +
+
+
+
+ + HeadlessAudioComponent + +
+
+
+ + + + + + + +
+
+
+ TuiAudioComponent +
+
+
+
+ + TuiAudioComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame2DAudioComponent +
+
+
+
+ + Monogame2DAudioComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame3DAudioComponent +
+
+
+
+ + Monogame3DAudioComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib2DAudioComponent +
+
+
+
+ + Raylib2DAudioComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib3DAudioComponent +
+
+
+
+ + Raylib3DAudioComponent + +
+
+
+ + + + + + + +
+
+
+ Stride3DAudioComponent +
+
+
+
+ + Stride3DAudioComponent + +
+
+
+ + + + + + + +
+
+
+ HeadlessAudioSource +
+
+
+
+ + HeadlessAudioSource + +
+
+
+ + + + + + + +
+
+
+ TuiAudioSource +
+
+
+
+ + TuiAudioSource + +
+
+
+ + + + + + + + +
+
+
+ Monogame2DAudioSource +
+
+
+
+ + Monogame2DAudioSource + +
+
+
+ + + + + + + +
+
+
+ Monogame3DAudioSource +
+
+
+
+ + Monogame3DAudioSource + +
+
+
+ + + + + + + +
+
+
+ Raylib2DAudioSource +
+
+
+
+ + Raylib2DAudioSource + +
+
+
+ + + + + + + +
+
+
+ Raylib3DAudioSource +
+
+
+
+ + Raylib3DAudioSource + +
+
+
+ + + + + + + +
+
+
+ Stride3DAudioSource +
+
+
+
+ + Stride3DAudioSource + +
+
+
+ + + + + + + +
+
+
+ NO-OP or logging implementations +
+
+
+
+ + NO-OP or logging imp... + +
+
+
+ + + + + + + +
+
+
+ 2D-only ASCII-art implementations +
+
+
+
+ + 2D-only ASCII-art im... + +
+
+
+ + + + + + + +
+
+
+ Do 2D and 3D actually need different implementations? +
+
+
+
+ + Do 2D and 3D actually need different implementations? + +
+
+
+ + + + + + + +
+
+
+ Do 2D and 3D actually need different implementations? +
+
+
+
+ + Do 2D and 3D actually need different implementations? + +
+
+
+ + + + + + + +
+
+
+ ICameraComponent +
+
+
+
+ + ICameraComponent + +
+
+
+ + + + + + + +
+
+
+ CameraDescriptor +
+
+
+
+ + CameraDescriptor + +
+
+
+ + + + + + + +
+
+
+ Stride already has an ECS implementation +
+
+
+
+ + Stride already has a... + +
+
+
+ + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessCameraComponent +
+
+
+
+ + HeadlessCameraComponent + +
+
+
+ + + + + + + +
+
+
+ TuiCameraComponent +
+
+
+
+ + TuiCameraComponent + +
+
+
+ + + + + + + +
+
+
+ MonogameCameraComponent +
+
+
+
+ + MonogameCameraComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame3DCameraComponent +
+
+
+
+ + Monogame3DCameraComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib2DCameraComponent +
+
+
+
+ + Raylib2DCameraComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib3DCameraComponent +
+
+
+
+ + Raylib3DCameraComponent + +
+
+
+ + + + + + + +
+
+
+ Stride3DCameraComponent +
+
+
+
+ + Stride3DCameraComponent + +
+
+
+ + + + + + + +
+
+
+ HeadlessCameraDescriptor +
+
+
+
+ + HeadlessCameraDescriptor + +
+
+
+ + + + + + + +
+
+
+ TuiCameraDescriptor +
+
+
+
+ + TuiCameraDescriptor + +
+
+
+ + + + + + + + +
+
+
+ Monogame2DCameraDescriptor +
+
+
+
+ + Monogame2DCameraDescriptor + +
+
+
+ + + + + + + +
+
+
+ Monogame3DCameraDescriptor +
+
+
+
+ + Monogame3DCameraDescriptor + +
+
+
+ + + + + + + +
+
+
+ Raylib2DCameraDescriptor +
+
+
+
+ + Raylib2DCameraDescriptor + +
+
+
+ + + + + + + +
+
+
+ Raylib3DCameraDescriptor +
+
+
+
+ + Raylib3DCameraDescriptor + +
+
+
+ + + + + + + +
+
+
+ Stride3DCameraComponent +
+
+
+
+ + Stride3DCameraComponent + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ Implements +
+
+
+
+ + Implements + +
+
+
+ + + + + + + +
+
+
+ HeadlessTransformComponent +
+
+
+
+ + HeadlessTransformComponent + +
+
+
+ + + + + + + +
+
+
+ TuiTransformComponent +
+
+
+
+ + TuiTransformComponent + +
+
+
+ + + + + + + +
+
+
+ Monogame2DTransformCom... +
+
+
+
+ + Monogame2DTransformCom... + +
+
+
+ + + + + + + +
+
+
+ Monogame3DTransformCom... +
+
+
+
+ + Monogame3DTransformCom... + +
+
+
+ + + + + + + +
+
+
+ Raylib2DTransformComponent +
+
+
+
+ + Raylib2DTransformComponent + +
+
+
+ + + + + + + +
+
+
+ Raylib3DTransformComponent +
+
+
+
+ + Raylib3DTransformComponent + +
+
+
+ + + + + + + +
+
+
+ Stride3DTransformComponent +
+
+
+
+ + Stride3DTransformComponent + +
+
+
+
+ + + + + Text is not SVG - cannot display + + + +
\ No newline at end of file diff --git a/docs/plans/phase-1-interface-development.md b/docs/plans/phase-1-interface-development.md index d7d75df..cafa7c7 100644 --- a/docs/plans/phase-1-interface-development.md +++ b/docs/plans/phase-1-interface-development.md @@ -9,22 +9,30 @@ Phase 1 defines the stable adapter contracts and DTO shapes used by all adapters - [Phase 1 — Interface development](#phase-1--interface-development) - [Overview](#overview) - [Table of contents](#table-of-contents) - - [Feature status](#feature-status) + - [Plan issue](#plan-issue) + - [Plan status](#plan-status) - [Definition of terms](#definition-of-terms) - [Architectural considerations and constraints](#architectural-considerations-and-constraints) - [Implementation guide](#implementation-guide) - - [Feature requirements](#feature-requirements) - - [Technical details](#technical-details) - - [Phase requirements](#phase-requirements) - - [API examples (minimal)](#api-examples-minimal) - - [Class diagram](#class-diagram) - - [DTO guidelines](#dto-guidelines) - - [Testing and compatibility](#testing-and-compatibility) - - [Performance targets](#performance-targets) + - [Plan requirements](#plan-requirements) + - [Phase 1 — Adapter contracts and DTOs](#phase-1--adapter-contracts-and-dtos) + - [Objective](#objective) + - [Technical details](#technical-details) + - [Phase requirements](#phase-requirements) + - [Examples](#examples) + - [Class diagram](#class-diagram) + - [DTO guidelines](#dto-guidelines) + - [Testing and compatibility](#testing-and-compatibility) + - [Performance targets](#performance-targets) + - [Known issues and design concerns](#known-issues-and-design-concerns) - [See also](#see-also) - [References](#references) -## Feature status +## Plan issue + +- [#2](https://github.com/JohnLudlow/GameEngineAdapter/issues/2) + +## Plan status Not started @@ -33,8 +41,11 @@ Not started | Term | Meaning | Reference | | ---- | ------- | --------- | | Adapter | An implementation binding the engine-agnostic contracts to a specific engine (MonoGame, Stride, Raylib, TUI, Headless). | | -| DTO | Data Transfer Object — compact, typically struct-based shapes passed across the adapter boundary. | | | ContractVersion | Semantic version string indicating the adapter contract shape. | | +| DTO | Data Transfer Object — compact, typically struct-based shapes passed across the adapter boundary. | | +| EngineConfig | Configuration data supplied when initializing an adapter (engine selection, resource paths, options). | | +| FrameScope | Disposable scope returned by BeginFrame; disposing finalizes the current frame (RAII pattern). | | +| Provider | A lightweight, adapter-owned service obtained from IEngineAdapter for a specific concern (rendering, input, UI, assets). | | ## Architectural considerations and constraints @@ -45,14 +56,22 @@ Not started ## Implementation guide -### Feature requirements +### Plan requirements - (***Not started***) API definitions committed with XML docs and examples. - GIVEN interface PR is opened - WHEN team reviews and approves - THEN interfaces are versioned and published for adapter implementations. -### Technical details +### Phase 1 — Adapter contracts and DTOs + +***Not started*** + +#### Objective + +Define stable adapter contracts and DTO shapes. Produce small, well-documented C# interfaces and compact DTOs that minimize allocation and provide a stable surface for engine adapters. + +#### Technical details - Define `IEngineAdapter` (lifecycle, capability descriptor, provider accessors such as `GetRenderProvider`, `GetInputProvider`). - Define provider interfaces (`IRenderProvider`, `IInputProvider`, `IUserInterfaceProvider`, `IAssetProvider`) that expose minimal, adapter-owned runtime surfaces and DTO translation helpers. @@ -60,111 +79,332 @@ Not started - Define `IAudioPlayer` (play/stop/volume, audio asset references) and `IAssetProvider`/`IAssetLoader` for asset lifecycle operations. - Provide capability descriptor model (`EngineCapabilities`) returned at adapter init and allow specific engine capability variants (e.g. `HeadlessEngineCapabilities`) to derive from the base capabilities. -### Phase requirements +#### Missing type definitions + +The following types are referenced by existing interfaces but do not yet have source files. They must be created in `src/GameEngineAdapter/` following the `readonly record struct` and single-file-per-type conventions. + +##### EngineConfig + +```csharp +/// +/// Configuration data for initializing an engine adapter. +/// +/// Adapter type or engine backend identifier (e.g. "MonoGame", "Stride"). +/// Optional path to engine resources or platform binaries. +/// Optional key-value configuration entries. +public readonly record struct EngineConfig( + string AdapterType, + string? ResourcePath, + IReadOnlyDictionary? Options); +``` + +##### FrameScope + +```csharp +/// +/// Disposable scope for a single render frame. Disposing finalizes the frame. +/// +public readonly record struct FrameScope : IDisposable +{ + /// Finalizes the current frame. + public void Dispose() { /* adapter-specific frame end logic */ } +} +``` + +##### SpriteDrawDto + +```csharp +/// +/// DTO for submitting a sprite draw command. +/// +/// Asset identifier for the sprite texture. +/// World-space transform for the sprite. +/// Material to apply when rendering. +/// Sort layer for deterministic draw ordering. +public readonly record struct SpriteDrawDto( + string SpriteId, + TransformDto Transform, + MaterialDto Material, + int Layer); +``` + +##### TextDrawDto + +```csharp +/// +/// DTO for submitting a text draw command. +/// +/// Text content to render. +/// World-space transform for the text. +/// Asset identifier for the font. +/// Font size in points. +/// Material to apply when rendering. +/// Sort layer for deterministic draw ordering. +public readonly record struct TextDrawDto( + string Text, + TransformDto Transform, + string FontId, + float FontSize, + MaterialDto Material, + int Layer); +``` + +##### MeshDrawDto + +```csharp +/// +/// DTO for submitting a mesh draw command. +/// +/// Asset identifier for the mesh. +/// World-space transform for the mesh. +/// Material to apply when rendering. +/// Sort layer for deterministic draw ordering. +public readonly record struct MeshDrawDto( + string MeshId, + TransformDto Transform, + MaterialDto Material, + int Layer); +``` + +##### IInputProvider + +```csharp +/// +/// Adapter-owned provider for polling input state (keyboard, mouse, gamepad). +/// +public interface IInputProvider +{ + /// Returns true if the specified key is currently pressed. + bool IsKeyDown(string key); + + /// Returns true if the specified mouse button is currently pressed. + bool IsMouseButtonDown(int button); + + /// Returns the current mouse position in screen coordinates. + (float X, float Y) GetMousePosition(); +} +``` + +##### IUserInterfaceProvider + +```csharp +/// +/// Adapter-owned provider for user interface operations. +/// +public interface IUserInterfaceProvider +{ + // Members to be defined based on UI requirements. +} +``` + +##### IAssetProvider + +```csharp +/// +/// Adapter-owned provider for asset lifecycle management (load, unload, query). +/// +public interface IAssetProvider +{ + /// Loads an asset by identifier and returns a handle. + object LoadAsset(string assetId); + + /// Unloads a previously loaded asset. + void UnloadAsset(string assetId); + + /// Returns true if the specified asset is currently loaded. + bool IsAssetLoaded(string assetId); +} +``` + +##### IAudioPlayer + +```csharp +/// +/// Interface for audio playback (play, stop, volume control). +/// +public interface IAudioPlayer +{ + /// Plays the specified audio asset. + void Play(string audioAssetId, bool loop = false); + + /// Stops playback of the specified audio asset. + void Stop(string audioAssetId); + + /// Sets the volume for the specified audio asset (0.0–1.0). + void SetVolume(string audioAssetId, float volume); +} +``` + +##### IAssetLoader + +```csharp +/// +/// Interface for async and sync asset loading, separate from the asset provider. +/// +public interface IAssetLoader +{ + /// Asynchronously loads an asset by identifier. + Task LoadAsync(string assetId, CancellationToken ct = default); + + /// Synchronously loads an asset by identifier. + object Load(string assetId); +} +``` + +#### Phase requirements - (***Not started***) Adapter lifecycle & capability negotiation - GIVEN adapters are present at startup - WHEN the game queries capabilities - THEN `IEngineAdapter` returns `EngineCapabilities` and exposes `Initialize`/`Shutdown` semantics and diagnostics for mismatches. -### API examples (minimal) +#### Examples ```csharp -// Example adapter root interface +/// +/// Root interface for an engine adapter. +/// public interface IEngineAdapter : IDisposable { + /// Gets the advertised capabilities of the engine adapter. EngineCapabilities Capabilities { get; } + + /// Initializes the engine adapter with the specified configuration. Task InitializeAsync(EngineConfig config, CancellationToken ct = default); + + /// Shuts down the engine adapter and releases resources. Task ShutdownAsync(CancellationToken ct = default); - // Provider accessors - adapters expose provider instances for rendering, input, UI and assets + /// Gets the provider for rendering operations. IRenderProvider GetRenderProvider(); + + /// Gets the provider for polling input state. IInputProvider GetInputProvider(); + + /// Gets the provider for user interface operations. IUserInterfaceProvider GetUserInterfaceProvider(); + + /// Gets the provider for asset management. IAssetProvider GetAssetProvider(); } ``` ```csharp -// Engine capabilities and camera descriptor examples -public readonly struct EngineCapabilities -{ - public readonly bool Supports2D; - public readonly bool Supports3D; - public readonly bool SupportsShaders; - public readonly bool SupportsAudio; - public readonly bool SupportsRichUI; - public readonly string ContractVersion; // semantic contract version - public readonly string[] SupportedTextureFormats; - public readonly int MaxTextureSize; - public readonly int MaxAudioChannels; -} - -// Example of a capabilities variant used by headless/test adapters -public readonly struct HeadlessEngineCapabilities -{ - public readonly EngineCapabilities Base; - public readonly bool SupportsOffscreenRendering; // offscreen frame encoding / trace capture - public readonly bool DeterministicTick; // indicates deterministic simulation support -} - +/// +/// Describes the capabilities and features supported by an engine adapter. +/// +/// Indicates if 2D rendering is supported. +/// Indicates if 3D rendering is supported. +/// Indicates if custom shaders are supported. +/// Indicates if audio playback is supported. +/// Indicates if rich UI features are supported. +/// The semantic version of the adapter contract. +/// List of texture formats supported by the engine. +/// Maximum supported texture size in pixels. +/// Maximum number of concurrent audio channels. +public readonly record struct EngineCapabilities( + bool Supports2D, + bool Supports3D, + bool SupportsShaders, + bool SupportsAudio, + bool SupportsRichUI, + string ContractVersion, + IReadOnlyList SupportedTextureFormats, + int MaxTextureSize, + int MaxAudioChannels); + +/// +/// Capabilities variant for headless or test-oriented adapters. +/// +/// Base engine capabilities. +/// Indicates if offscreen rendering/capture is supported. +/// Indicates if the engine supports a deterministic update tick. +public readonly record struct HeadlessEngineCapabilities( + EngineCapabilities Base, + bool SupportsOffscreenRendering, + bool DeterministicTick); + +/// +/// Defines the type of camera projection. +/// public enum ProjectionType { Orthographic, Perspective } -public readonly struct CameraDescriptor -{ - public readonly ProjectionType Projection; - public readonly float FieldOfViewDegrees; // used for perspective - public readonly float OrthographicSize; // used for orthographic - public readonly float AspectRatio; - public readonly float NearPlane; - public readonly float FarPlane; - // Camera transform is supplied separately via TransformDto when submitting world-space primitives -} +/// +/// Describes camera configuration for a render frame. +/// +/// The projection model to use. +/// Field of view in degrees (for perspective projection). +/// Size of the orthographic view (for orthographic projection). +/// Aspect ratio of the view. +/// Distance to the near clipping plane. +/// Distance to the far clipping plane. +public readonly record struct CameraDescriptor( + ProjectionType Projection, + float FieldOfViewDegrees, + float OrthographicSize, + float AspectRatio, + float NearPlane, + float FarPlane); ``` ```csharp -// Example render context and material DTOs +/// +/// Provider for rendering operations and submitting draw commands. +/// public interface IRenderProvider { /// - /// Called at the start of a frame. Adapter configures view/projection state - /// from and returns a - /// which when disposed will finalise the frame. + /// Starts a new render frame with the specified camera configuration. /// + /// Camera configuration for the frame. + /// A scope that finalizes the frame when disposed. FrameScope BeginFrame(in CameraDescriptor camera); + /// Submits a sprite for rendering. void SubmitSprite(in SpriteDrawDto dto); + + /// Submits text for rendering. void SubmitText(in TextDrawDto dto); + + /// Submits a mesh for rendering. void SubmitMesh(in MeshDrawDto dto); + /// Ends the current render frame. void EndFrame(); + + /// Presents the rendered frame to the display. void Present(); } - -public readonly struct MaterialDescriptor { public string ShaderId; public Dictionary Uniforms; public int[] TextureSlots; } ``` ```csharp -// Transform and material DTO examples -public readonly struct TransformDto -{ - public readonly float X; - public readonly float Y; - public readonly float Z; - public readonly float RotationX; - public readonly float RotationY; - public readonly float RotationZ; - public readonly float ScaleX; - public readonly float ScaleY; - public readonly float ScaleZ; -} - -public readonly struct MaterialDto -{ - public readonly string ShaderId; - public readonly IReadOnlyDictionary Uniforms; // engine-specific mapping - public readonly int[] TextureSlots; -} +/// +/// World-space transform for an object. +/// +/// X coordinate of the position. +/// Y coordinate of the position. +/// Z coordinate of the position. +/// Rotation around the X axis. +/// Rotation around the Y axis. +/// Rotation around the Z axis. +/// Scale along the X axis. +/// Scale along the Y axis. +/// Scale along the Z axis. +public readonly record struct TransformDto( + float X, float Y, float Z, + float RotationX, float RotationY, float RotationZ, + float ScaleX, float ScaleY, float ScaleZ); + +/// +/// Data transfer object for material configuration. +/// +/// Identifier for the shader to use. +/// Dictionary of shader uniform values. +/// Indices of texture slots to bind. +public readonly record struct MaterialDto( + string ShaderId, + IReadOnlyDictionary Uniforms, + IReadOnlyList TextureSlots); ``` ### Class diagram @@ -173,7 +413,7 @@ public readonly struct MaterialDto ### DTO guidelines -- Use compact DTOs (prefer `struct`) for draw lists to reduce GC pressure. +- Use compact DTOs (prefer `readonly record struct`) for draw lists to reduce GC pressure. - Provide a small set of primitive types (Sprite, Text, Rect, MeshReference, MaterialDescriptor). - Include an explicit `Transform` and `Layer`/`SortKey` for deterministic ordering. @@ -188,6 +428,12 @@ public readonly struct MaterialDto - Aim for single-digit microseconds per DTO translation on typical hardware for hot paths. - Maintain allocation budgets per frame (document expected allocations for UI-heavy vs simulation-heavy ticks). +### Known issues and design concerns + +- **MaterialDescriptor compile error**: `MaterialDescriptor.cs` declares a `readonly struct` but its fields (`ShaderId`, `Uniforms`, `TextureSlots`) are not marked `readonly`, causing CS8340 errors. Convert to a `readonly record struct` to match the updated convention and eliminate the manual field declarations. +- **MaterialDescriptor vs MaterialDto duplication**: Both types carry `ShaderId`, `Uniforms`/`Dictionary`, and `TextureSlots`. Their distinct roles should be clarified — e.g. `MaterialDescriptor` as the engine-facing definition and `MaterialDto` as the cross-boundary transfer object — or they should be consolidated into a single type. +- **IAssetProvider vs IAssetLoader**: The plan references both. Clarify whether these are separate concerns (provider = lifecycle management, loader = I/O) or should be merged. + ## See also - Parent plan: [Engine Decoupling](https://github.com/JohnLudlow/FourXGame/blob/main/docs/plans/4x-game/technical/engine-decoupling/engine-decoupling.md) diff --git a/scripts/check-doc-links.ps1 b/scripts/check-doc-links.ps1 deleted file mode 100644 index 529ef58..0000000 --- a/scripts/check-doc-links.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -param( - [string]$DocsPath = "docs" -) - -# Lints markdown links in the docs folder to ensure they don't escape the repo root -# and they don't use absolute local paths. Exits with non-zero code on violations. - -# Determine repo root relative to the script location; fall back to current location -try { - $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -} catch { - $repoRoot = (Get-Location).Path -} -# Normalize for comparisons -$repoRoot = [System.IO.Path]::GetFullPath($repoRoot) -$errors = @() - -# Gather markdown files -$mdFiles = Get-ChildItem -Path $DocsPath -Filter *.md -Recurse -ErrorAction Stop - -foreach ($file in $mdFiles) { - $lines = Get-Content -LiteralPath $file.FullName - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - $match = [regex]::Match($line, "\[.*?\]\((.*?)\)") - while ($match.Success) { - $target = $match.Groups[1].Value - - # Allow external links - if ([regex]::IsMatch($target, "^(https?://|mailto:)")) { $match = $match.NextMatch(); continue } - - # Disallow absolute local paths and root-anchored paths - if ([regex]::IsMatch($target, "^[A-Za-z]:\\")) { - $errors += @{ File = $file.FullName; Line = $i + 1; Issue = "Absolute local path"; Target = $target } - $match = $match.NextMatch(); continue - } - if ([regex]::IsMatch($target, "^/")) { - $errors += @{ File = $file.FullName; Line = $i + 1; Issue = "Root-anchored path"; Target = $target } - $match = $match.NextMatch(); continue - } - if ([regex]::IsMatch($target, "^(file|vscode)://")) { - $errors += @{ File = $file.FullName; Line = $i + 1; Issue = "Unsupported URI scheme"; Target = $target } - $match = $match.NextMatch(); continue - } - - # Root-escape detection handled via absolute path resolution below - - # Strip fragment and query for existence check - $pathOnly = $target.Split('#')[0].Split('?')[0] - # Normalize slashes for Windows filesystem check - $pathFs = $pathOnly -replace "/", "\\" - - try { - $abs = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($file.DirectoryName, $pathFs)) - } catch { - $abs = $null - } - - if ($abs -and $abs.ToLower().StartsWith($repoRoot.ToLower())) { - # Check existence for local relative links only - if (-not (Test-Path -LiteralPath $abs)) { - $errors += @{ File = $file.FullName; Line = $i + 1; Issue = "Missing target"; Target = $target } - } - } elseif ($abs) { - $errors += @{ File = $file.FullName; Line = $i + 1; Issue = "Resolved outside repo"; Target = $target } - } - $match = $match.NextMatch() - } - } -} - -if ($errors.Count -gt 0) { - Write-Host "Docs link check found $($errors.Count) issue(s):" -ForegroundColor Red - foreach ($e in $errors) { - Write-Host " - $($e.File):$($e.Line) -> $($e.Issue): $($e.Target)" - } - exit 1 -} else { - Write-Host "Docs link check passed: no invalid links." -ForegroundColor Green -} diff --git a/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj b/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj index db296aa..618a3b8 100644 --- a/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj +++ b/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj @@ -5,6 +5,7 @@ enable enable false + JohnLudlow.GameEngineAdapter.UnitTests diff --git a/src/GameEngineAdapter/GameEngineAdapter.csproj b/src/GameEngineAdapter/GameEngineAdapter.csproj index d20809e..87238be 100644 --- a/src/GameEngineAdapter/GameEngineAdapter.csproj +++ b/src/GameEngineAdapter/GameEngineAdapter.csproj @@ -1,10 +1,11 @@ - - - - Exe - net10.0 - enable - enable - - - + + + + net10.0 + enable + enable + + JohnLudlow.GameEngineAdapter + + + diff --git a/src/GameEngineAdapter/Program.cs b/src/GameEngineAdapter/Program.cs deleted file mode 100644 index 193a0ed..0000000 --- a/src/GameEngineAdapter/Program.cs +++ /dev/null @@ -1 +0,0 @@ -Console.WriteLine("Hello, World!"); \ No newline at end of file From b4a93dee2f0a03e69e9f3c4c01d70b05395a3015 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Sat, 11 Apr 2026 18:18:51 +0100 Subject: [PATCH 02/19] Initial interfaces --- .gitattributes | 2 +- src/GameEngineAdapter/EngineConfig.cs | 13 +++++++++++++ src/GameEngineAdapter/FrameScope.cs | 10 ++++++++++ src/GameEngineAdapter/MaterialDto.cs | 12 ++++++++++++ src/GameEngineAdapter/MeshDrawDto.cs | 14 ++++++++++++++ src/GameEngineAdapter/SpriteDrawDto.cs | 14 ++++++++++++++ src/GameEngineAdapter/TextDrawDto.cs | 18 ++++++++++++++++++ src/GameEngineAdapter/TransformDto.cs | 18 ++++++++++++++++++ 8 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/GameEngineAdapter/EngineConfig.cs create mode 100644 src/GameEngineAdapter/FrameScope.cs create mode 100644 src/GameEngineAdapter/MaterialDto.cs create mode 100644 src/GameEngineAdapter/MeshDrawDto.cs create mode 100644 src/GameEngineAdapter/SpriteDrawDto.cs create mode 100644 src/GameEngineAdapter/TextDrawDto.cs create mode 100644 src/GameEngineAdapter/TransformDto.cs diff --git a/.gitattributes b/.gitattributes index ec2855e..fbd75d3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -* text = lf \ No newline at end of file +* text=lf \ No newline at end of file diff --git a/src/GameEngineAdapter/EngineConfig.cs b/src/GameEngineAdapter/EngineConfig.cs new file mode 100644 index 0000000..c79fae4 --- /dev/null +++ b/src/GameEngineAdapter/EngineConfig.cs @@ -0,0 +1,13 @@ +namespace JohnLudlow.GameEngineAdapter; + + +/// +/// Configuration data for initializing an engine adapter. +/// +/// Adapter type or engine backend identifier (e.g. "MonoGame", "Stride"). +/// Optional path to engine resources or platform binaries. +/// Optional key-value configuration entries. +public readonly record struct EngineConfig( + string AdapterName, + string? ResourcePath, + IReadOnlyDictionary? Options); diff --git a/src/GameEngineAdapter/FrameScope.cs b/src/GameEngineAdapter/FrameScope.cs new file mode 100644 index 0000000..7df42ef --- /dev/null +++ b/src/GameEngineAdapter/FrameScope.cs @@ -0,0 +1,10 @@ +namespace JohnLudlow.GameEngineAdapter; + +/// +/// Disposable scope for a single render frame. Disposing finalizes the frame. +/// +public readonly record struct FrameScope : IDisposable +{ + /// Finalizes the current frame. + public void Dispose() { /* adapter-specific frame end logic */ } +} diff --git a/src/GameEngineAdapter/MaterialDto.cs b/src/GameEngineAdapter/MaterialDto.cs new file mode 100644 index 0000000..0d2258e --- /dev/null +++ b/src/GameEngineAdapter/MaterialDto.cs @@ -0,0 +1,12 @@ +namespace JohnLudlow.GameEngineAdapter; + +/// +/// Data transfer object for material configuration. +/// +/// Identifier for the shader to use. +/// Dictionary of shader uniform values. +/// Indices of texture slots to bind. +public readonly record struct MaterialDto( + string ShaderId, + IReadOnlyDictionary Uniforms, + IReadOnlyList TextureSlots); diff --git a/src/GameEngineAdapter/MeshDrawDto.cs b/src/GameEngineAdapter/MeshDrawDto.cs new file mode 100644 index 0000000..a2b956b --- /dev/null +++ b/src/GameEngineAdapter/MeshDrawDto.cs @@ -0,0 +1,14 @@ +namespace JohnLudlow.GameEngineAdapter; + +/// +/// DTO for submitting a mesh draw command. +/// +/// Asset identifier for the mesh. +/// World-space transform for the mesh. +/// Material to apply when rendering. +/// Sort layer for deterministic draw ordering. +public readonly record struct MeshDrawDto( + string MeshId, + TransformDto Transform, + MaterialDto Material, + int Layer); \ No newline at end of file diff --git a/src/GameEngineAdapter/SpriteDrawDto.cs b/src/GameEngineAdapter/SpriteDrawDto.cs new file mode 100644 index 0000000..f1eaa15 --- /dev/null +++ b/src/GameEngineAdapter/SpriteDrawDto.cs @@ -0,0 +1,14 @@ +namespace JohnLudlow.GameEngineAdapter; + +/// +/// DTO for submitting a sprite draw command. +/// +/// Asset identifier for the sprite texture. +/// World-space transform for the sprite. +/// Material to apply when rendering. +/// Sort layer for deterministic draw ordering. +public readonly record struct SpriteDrawDto( + string SpriteId, + TransformDto Transform, + MaterialDto Material, + int Layer); diff --git a/src/GameEngineAdapter/TextDrawDto.cs b/src/GameEngineAdapter/TextDrawDto.cs new file mode 100644 index 0000000..bf325b6 --- /dev/null +++ b/src/GameEngineAdapter/TextDrawDto.cs @@ -0,0 +1,18 @@ +namespace JohnLudlow.GameEngineAdapter; + +/// +/// DTO for submitting a text draw command. +/// +/// Text content to render. +/// World-space transform for the text. +/// Asset identifier for the font. +/// Font size in points. +/// Material to apply when rendering. +/// Sort layer for deterministic draw ordering. +public readonly record struct TextDrawDto( + string Text, + TransformDto Transform, + string FontId, + float FontSize, + MaterialDto Material, + int Layer); diff --git a/src/GameEngineAdapter/TransformDto.cs b/src/GameEngineAdapter/TransformDto.cs new file mode 100644 index 0000000..6f9c4c7 --- /dev/null +++ b/src/GameEngineAdapter/TransformDto.cs @@ -0,0 +1,18 @@ +namespace JohnLudlow.GameEngineAdapter; + +/// +/// World-space transform for an object. +/// +/// X coordinate of the position. +/// Y coordinate of the position. +/// Z coordinate of the position. +/// Rotation around the X axis. +/// Rotation around the Y axis. +/// Rotation around the Z axis. +/// Scale along the X axis. +/// Scale along the Y axis. +/// Scale along the Z axis. +public readonly record struct TransformDto( + float X, float Y, float Z, + float RotationX, float RotationY, float RotationZ, + float ScaleX, float ScaleY, float ScaleZ); From be52d086fb3c73f2b4dc0ab5d4a67f3e7d2cd8c0 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Sat, 11 Apr 2026 20:26:00 +0100 Subject: [PATCH 03/19] Add unit tests for game engine adapter components - Implement tests for EngineCapabilities, EngineConfig, and HeadlessAdapter to ensure proper construction and functionality. - Add tests for asset loading and management in HeadlessAssetLoader and HeadlessAssetProvider. - Create tests for audio playback functionality in HeadlessAudioPlayer. - Validate rendering commands in HeadlessRenderProvider with tests for sprite, text, and mesh submissions. - Introduce tests for input handling in HeadlessInputProvider. - Ensure proper functionality of DTOs (Data Transfer Objects) including MaterialDto, MeshDrawDto, SpriteDrawDto, TextDrawDto, and TransformDto. - Add tests for the TestAdapter to verify recorded calls and delegation to inner providers. --- GameEngineAdapter.slnx | 13 +- docs/plans/phase-1-interface-development.md | 85 ++- .../phase-2-headless-adapter-development.md | 614 ++++++++++++++++++ .../CameraDescriptor.cs | 23 + .../EngineCapabilities.cs | 25 + .../EngineConfig.cs | 2 +- .../FrameScope.cs | 2 +- .../GameEngineAdapter.Core.csproj} | 2 +- .../HeadlessEngineCapabilities.cs | 12 + src/GameEngineAdapter.Core/IAssetLoader.cs | 14 + src/GameEngineAdapter.Core/IAssetProvider.cs | 23 + src/GameEngineAdapter.Core/IAudioPlayer.cs | 16 + src/GameEngineAdapter.Core/IEngineAdapter.cs | 31 + src/GameEngineAdapter.Core/IInputProvider.cs | 16 + src/GameEngineAdapter.Core/IRenderProvider.cs | 29 + .../IUserInterfaceProvider.cs | 9 + .../MaterialDto.cs | 2 +- .../MeshDrawDto.cs | 2 +- .../SpriteDrawDto.cs | 2 +- .../TextDrawDto.cs | 2 +- .../TransformDto.cs | 2 +- .../DeterministicEngineRunner.cs | 45 ++ .../GameEngineAdapter.Headless.csproj | 14 + .../HeadlessAdapter.cs | 66 ++ .../HeadlessAssetLoader.cs | 16 + .../HeadlessAssetProvider.cs | 49 ++ .../HeadlessAudioPlayer.cs | 31 + .../HeadlessInputProvider.cs | 61 ++ .../HeadlessRenderProvider.cs | 35 + .../HeadlessUserInterfaceProvider.cs | 12 + .../RecordedCall.cs | 12 + src/GameEngineAdapter.Headless/TestAdapter.cs | 59 ++ .../CameraDescriptorTests.cs | 67 ++ .../DeterministicEngineRunnerTests.cs | 79 +++ .../EngineCapabilitiesTests.cs | 60 ++ .../EngineConfigTests.cs | 72 ++ .../GameEngineAdapter.UnitTests.csproj | 5 + .../HeadlessAdapterTests.cs | 123 ++++ .../HeadlessAssetLoaderTests.cs | 56 ++ .../HeadlessAssetProviderTests.cs | 111 ++++ .../HeadlessAudioPlayerTests.cs | 114 ++++ .../HeadlessEngineCapabilitiesTests.cs | 57 ++ .../HeadlessInputProviderTests.cs | 143 ++++ .../HeadlessRenderProviderTests.cs | 136 ++++ .../MaterialDtoTests.cs | 63 ++ .../MeshDrawDtoTests.cs | 60 ++ .../RecordedCallTests.cs | 74 +++ .../SpriteDrawDtoTests.cs | 60 ++ .../TestAdapterTests.cs | 155 +++++ .../TextDrawDtoTests.cs | 64 ++ .../TransformDtoTests.cs | 64 ++ src/GameEngineAdapter.UnitTests/UnitTest1.cs | 10 - 52 files changed, 2851 insertions(+), 48 deletions(-) create mode 100644 docs/plans/phase-2-headless-adapter-development.md create mode 100644 src/GameEngineAdapter.Core/CameraDescriptor.cs create mode 100644 src/GameEngineAdapter.Core/EngineCapabilities.cs rename src/{GameEngineAdapter => GameEngineAdapter.Core}/EngineConfig.cs (91%) rename src/{GameEngineAdapter => GameEngineAdapter.Core}/FrameScope.cs (86%) rename src/{GameEngineAdapter/GameEngineAdapter.csproj => GameEngineAdapter.Core/GameEngineAdapter.Core.csproj} (75%) create mode 100644 src/GameEngineAdapter.Core/HeadlessEngineCapabilities.cs create mode 100644 src/GameEngineAdapter.Core/IAssetLoader.cs create mode 100644 src/GameEngineAdapter.Core/IAssetProvider.cs create mode 100644 src/GameEngineAdapter.Core/IAudioPlayer.cs create mode 100644 src/GameEngineAdapter.Core/IEngineAdapter.cs create mode 100644 src/GameEngineAdapter.Core/IInputProvider.cs create mode 100644 src/GameEngineAdapter.Core/IRenderProvider.cs create mode 100644 src/GameEngineAdapter.Core/IUserInterfaceProvider.cs rename src/{GameEngineAdapter => GameEngineAdapter.Core}/MaterialDto.cs (90%) rename src/{GameEngineAdapter => GameEngineAdapter.Core}/MeshDrawDto.cs (91%) rename src/{GameEngineAdapter => GameEngineAdapter.Core}/SpriteDrawDto.cs (91%) rename src/{GameEngineAdapter => GameEngineAdapter.Core}/TextDrawDto.cs (93%) rename src/{GameEngineAdapter => GameEngineAdapter.Core}/TransformDto.cs (94%) create mode 100644 src/GameEngineAdapter.Headless/DeterministicEngineRunner.cs create mode 100644 src/GameEngineAdapter.Headless/GameEngineAdapter.Headless.csproj create mode 100644 src/GameEngineAdapter.Headless/HeadlessAdapter.cs create mode 100644 src/GameEngineAdapter.Headless/HeadlessAssetLoader.cs create mode 100644 src/GameEngineAdapter.Headless/HeadlessAssetProvider.cs create mode 100644 src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs create mode 100644 src/GameEngineAdapter.Headless/HeadlessInputProvider.cs create mode 100644 src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs create mode 100644 src/GameEngineAdapter.Headless/HeadlessUserInterfaceProvider.cs create mode 100644 src/GameEngineAdapter.Headless/RecordedCall.cs create mode 100644 src/GameEngineAdapter.Headless/TestAdapter.cs create mode 100644 src/GameEngineAdapter.UnitTests/CameraDescriptorTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/EngineCapabilitiesTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/EngineConfigTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessAdapterTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessAssetLoaderTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessAssetProviderTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessEngineCapabilitiesTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessInputProviderTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/MaterialDtoTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/MeshDrawDtoTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/RecordedCallTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/SpriteDrawDtoTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/TestAdapterTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/TextDrawDtoTests.cs create mode 100644 src/GameEngineAdapter.UnitTests/TransformDtoTests.cs delete mode 100644 src/GameEngineAdapter.UnitTests/UnitTest1.cs diff --git a/GameEngineAdapter.slnx b/GameEngineAdapter.slnx index 7600562..c6f9d7f 100644 --- a/GameEngineAdapter.slnx +++ b/GameEngineAdapter.slnx @@ -1,6 +1,7 @@ - - - - - - + + + + + + + diff --git a/docs/plans/phase-1-interface-development.md b/docs/plans/phase-1-interface-development.md index cafa7c7..65a76ba 100644 --- a/docs/plans/phase-1-interface-development.md +++ b/docs/plans/phase-1-interface-development.md @@ -34,7 +34,7 @@ Phase 1 defines the stable adapter contracts and DTO shapes used by all adapters ## Plan status -Not started +Complete ## Definition of terms @@ -58,14 +58,14 @@ Not started ### Plan requirements -- (***Not started***) API definitions committed with XML docs and examples. +- (***Complete***) API definitions committed with XML docs and examples. - GIVEN interface PR is opened - WHEN team reviews and approves - THEN interfaces are versioned and published for adapter implementations. ### Phase 1 — Adapter contracts and DTOs -***Not started*** +***Complete*** #### Objective @@ -73,27 +73,29 @@ Define stable adapter contracts and DTO shapes. Produce small, well-documented C #### Technical details -- Define `IEngineAdapter` (lifecycle, capability descriptor, provider accessors such as `GetRenderProvider`, `GetInputProvider`). -- Define provider interfaces (`IRenderProvider`, `IInputProvider`, `IUserInterfaceProvider`, `IAssetProvider`) that expose minimal, adapter-owned runtime surfaces and DTO translation helpers. +- Define `IEngineAdapter` (lifecycle, capability descriptor, provider property accessors such as `RenderProvider`, `InputProvider`, `AudioPlayer`). +- Define provider interfaces (`IRenderProvider`, `IInputProvider`, `IUserInterfaceProvider`, `IAssetProvider`) that expose minimal, adapter-owned runtime surfaces and DTO translation helpers. Providers are exposed as read-only properties on `IEngineAdapter`. - Providers are intentionally lightweight and adapter-owned: callers obtain a provider from the adapter and submit DTOs or poll events through that provider. -- Define `IAudioPlayer` (play/stop/volume, audio asset references) and `IAssetProvider`/`IAssetLoader` for asset lifecycle operations. +- Define `IAudioPlayer` (play/stop/volume, audio asset references), `IAssetProvider` for asset caching and lifecycle management (unload, query, cache eviction), and `IAssetLoader` for loading assets from storage into the asset provider. - Provide capability descriptor model (`EngineCapabilities`) returned at adapter init and allow specific engine capability variants (e.g. `HeadlessEngineCapabilities`) to derive from the base capabilities. #### Missing type definitions -The following types are referenced by existing interfaces but do not yet have source files. They must be created in `src/GameEngineAdapter/` following the `readonly record struct` and single-file-per-type conventions. +The following types are referenced by existing interfaces but do not yet have source files. They must be created in `src/GameEngineAdapter/` under the `JohnLudlow.GameEngineAdapter.Core` namespace, following the `readonly record struct` and single-file-per-type conventions. ##### EngineConfig ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Configuration data for initializing an engine adapter. /// -/// Adapter type or engine backend identifier (e.g. "MonoGame", "Stride"). +/// Adapter type or engine backend identifier (e.g. "MonoGame", "Stride"). /// Optional path to engine resources or platform binaries. /// Optional key-value configuration entries. public readonly record struct EngineConfig( - string AdapterType, + string AdapterName, string? ResourcePath, IReadOnlyDictionary? Options); ``` @@ -101,6 +103,8 @@ public readonly record struct EngineConfig( ##### FrameScope ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Disposable scope for a single render frame. Disposing finalizes the frame. /// @@ -114,6 +118,8 @@ public readonly record struct FrameScope : IDisposable ##### SpriteDrawDto ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// DTO for submitting a sprite draw command. /// @@ -131,6 +137,8 @@ public readonly record struct SpriteDrawDto( ##### TextDrawDto ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// DTO for submitting a text draw command. /// @@ -152,6 +160,8 @@ public readonly record struct TextDrawDto( ##### MeshDrawDto ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// DTO for submitting a mesh draw command. /// @@ -169,6 +179,8 @@ public readonly record struct MeshDrawDto( ##### IInputProvider ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Adapter-owned provider for polling input state (keyboard, mouse, gamepad). /// @@ -188,6 +200,8 @@ public interface IInputProvider ##### IUserInterfaceProvider ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Adapter-owned provider for user interface operations. /// @@ -200,25 +214,36 @@ public interface IUserInterfaceProvider ##### IAssetProvider ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// -/// Adapter-owned provider for asset lifecycle management (load, unload, query). +/// Adapter-owned provider for asset lifecycle management, caching, and querying. +/// Does not load assets directly — delegates to for I/O. /// public interface IAssetProvider { - /// Loads an asset by identifier and returns a handle. - object LoadAsset(string assetId); + /// Returns the asset loader used by this provider. + IAssetLoader Loader { get; } + + /// Returns a previously loaded asset by identifier, or null if not cached. + object? GetAsset(string assetId); - /// Unloads a previously loaded asset. + /// Unloads a previously loaded asset and removes it from the cache. void UnloadAsset(string assetId); - /// Returns true if the specified asset is currently loaded. + /// Returns true if the specified asset is currently loaded and cached. bool IsAssetLoaded(string assetId); + + /// Evicts all cached assets. + void ClearCache(); } ``` ##### IAudioPlayer ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Interface for audio playback (play, stop, volume control). /// @@ -238,8 +263,11 @@ public interface IAudioPlayer ##### IAssetLoader ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// -/// Interface for async and sync asset loading, separate from the asset provider. +/// Interface for loading assets from storage (disk, network, embedded resources). +/// Separate from which handles caching and lifecycle. /// public interface IAssetLoader { @@ -253,7 +281,7 @@ public interface IAssetLoader #### Phase requirements -- (***Not started***) Adapter lifecycle & capability negotiation +- (***Complete***) Adapter lifecycle & capability negotiation - GIVEN adapters are present at startup - WHEN the game queries capabilities - THEN `IEngineAdapter` returns `EngineCapabilities` and exposes `Initialize`/`Shutdown` semantics and diagnostics for mismatches. @@ -261,6 +289,8 @@ public interface IAssetLoader #### Examples ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Root interface for an engine adapter. /// @@ -276,20 +306,25 @@ public interface IEngineAdapter : IDisposable Task ShutdownAsync(CancellationToken ct = default); /// Gets the provider for rendering operations. - IRenderProvider GetRenderProvider(); + IRenderProvider RenderProvider { get; } /// Gets the provider for polling input state. - IInputProvider GetInputProvider(); + IInputProvider InputProvider { get; } /// Gets the provider for user interface operations. - IUserInterfaceProvider GetUserInterfaceProvider(); + IUserInterfaceProvider UserInterfaceProvider { get; } /// Gets the provider for asset management. - IAssetProvider GetAssetProvider(); + IAssetProvider AssetProvider { get; } + + /// Gets the audio player for audio playback. + IAudioPlayer AudioPlayer { get; } } ``` ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Describes the capabilities and features supported by an engine adapter. /// @@ -348,6 +383,8 @@ public readonly record struct CameraDescriptor( ``` ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// Provider for rendering operations and submitting draw commands. /// @@ -378,6 +415,8 @@ public interface IRenderProvider ``` ```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + /// /// World-space transform for an object. /// @@ -414,7 +453,7 @@ public readonly record struct MaterialDto( ### DTO guidelines - Use compact DTOs (prefer `readonly record struct`) for draw lists to reduce GC pressure. -- Provide a small set of primitive types (Sprite, Text, Rect, MeshReference, MaterialDescriptor). +- Provide a small set of primitive types (Sprite, Text, Rect, MeshReference, MaterialDto). - Include an explicit `Transform` and `Layer`/`SortKey` for deterministic ordering. ### Testing and compatibility @@ -430,9 +469,7 @@ public readonly record struct MaterialDto( ### Known issues and design concerns -- **MaterialDescriptor compile error**: `MaterialDescriptor.cs` declares a `readonly struct` but its fields (`ShaderId`, `Uniforms`, `TextureSlots`) are not marked `readonly`, causing CS8340 errors. Convert to a `readonly record struct` to match the updated convention and eliminate the manual field declarations. -- **MaterialDescriptor vs MaterialDto duplication**: Both types carry `ShaderId`, `Uniforms`/`Dictionary`, and `TextureSlots`. Their distinct roles should be clarified — e.g. `MaterialDescriptor` as the engine-facing definition and `MaterialDto` as the cross-boundary transfer object — or they should be consolidated into a single type. -- **IAssetProvider vs IAssetLoader**: The plan references both. Clarify whether these are separate concerns (provider = lifecycle management, loader = I/O) or should be merged. +- **MaterialDescriptor removed**: `MaterialDescriptor.cs` previously declared a `readonly struct` with non-`readonly` fields, causing CS8340 errors. The type has been removed from the codebase. `MaterialDto` is now the single material DTO. If an engine-facing material definition is needed in future, define it as a `readonly record struct`. ## See also diff --git a/docs/plans/phase-2-headless-adapter-development.md b/docs/plans/phase-2-headless-adapter-development.md new file mode 100644 index 0000000..db801ec --- /dev/null +++ b/docs/plans/phase-2-headless-adapter-development.md @@ -0,0 +1,614 @@ +# Phase 2 — Headless adapter development + +## Overview + +Phase 2 implements a minimal, dependency-free headless adapter for the GameEngineAdapter project. This adapter is housed in a separate assembly (`GameEngineAdapter.Headless`) under the `JohnLudlow.GameEngineAdapter.Headless` namespace, referencing the core contracts from `JohnLudlow.GameEngineAdapter.Core`. It enables deterministic, reproducible integration testing and CI runs by simulating rendering, input, and audio operations without any native platform dependencies. The HeadlessAdapter records render commands, simulates input via scripted events, and provides deterministic execution modes (fixed timestep, seeded RNG) to ensure stable test results. A TestAdapter is also provided for CI environments to record and assert adapter calls. + +## Table of contents + +- [Phase 2 — Headless adapter development](#phase-2--headless-adapter-development) + - [Overview](#overview) + - [Table of contents](#table-of-contents) + - [Plan issue](#plan-issue) + - [Plan status](#plan-status) + - [Definition of terms](#definition-of-terms) + - [Architectural considerations and constraints](#architectural-considerations-and-constraints) + - [Implementation guide](#implementation-guide) + - [Plan requirements](#plan-requirements) + - [Phase 1 — HeadlessAdapter core](#phase-1--headlessadapter-core) + - [Objective](#objective) + - [Technical details](#technical-details) + - [Phase requirements](#phase-requirements) + - [Examples](#examples) + - [Phase 2 — TestAdapter for CI](#phase-2--testadapter-for-ci) + - [Objective](#objective-1) + - [Technical details](#technical-details-1) + - [Phase requirements](#phase-requirements-1) + - [Examples](#examples-1) + - [Phase 3 — Deterministic execution](#phase-3--deterministic-execution) + - [Objective](#objective-2) + - [Technical details](#technical-details-2) + - [Phase requirements](#phase-requirements-2) + - [Examples](#examples-2) + - [Testing and compatibility](#testing-and-compatibility) + - [Performance targets](#performance-targets) + - [Known issues and design concerns](#known-issues-and-design-concerns) + - [See also](#see-also) + - [References](#references) + +## Plan issue + +- [#5](https://github.com/JohnLudlow/GameEngineAdapter/issues/5) + +## Plan status + +Complete + +## Definition of terms + +| Term | Meaning | Reference | +| ---- | ------- | --------- | +| Adapter | A class that implements the Phase 1 interfaces to abstract platform-specific functionality for a given engine or runtime. | | +| Deterministic tick | A simulation step that produces identical results given the same initial state and inputs. | | +| Fixed timestep | A simulation approach where each update advances time by a constant interval, avoiding variable frame rate effects. | | +| HeadlessAdapter | An adapter implementation that simulates engine operations without platform or GPU dependencies. | | +| No-op | An operation or method that performs no action (no operation), used to satisfy interface contracts without side effects. | | +| Seeded RNG | A random number generator initialized with a specific seed to produce reproducible sequences across runs. | | +| TestAdapter | An adapter implementation that records all calls for verification and assertion in automated tests. | | + +## Architectural considerations and constraints + +- **Separate assembly**: The headless adapter lives in a dedicated assembly (`GameEngineAdapter.Headless`) under the `JohnLudlow.GameEngineAdapter.Headless` namespace. This isolates the headless implementation from the core contracts defined in `JohnLudlow.GameEngineAdapter.Core`, ensuring engine-specific adapters do not depend on each other. The headless assembly references the core assembly via a project reference. +- **Dependency on Phase 1 interfaces**: The HeadlessAdapter and TestAdapter must implement all interfaces defined in Phase 1 (`IEngineAdapter`, `IRenderProvider`, `IInputProvider`, `IUserInterfaceProvider`, `IAssetProvider`, `IAudioPlayer`, `IAssetLoader`). Implementation cannot begin until Phase 1 interfaces are finalized. +- **No native platform dependencies**: The implementation must not use any native APIs, GPU, audio hardware, or OS-specific features. All adapters must run on any platform supported by .NET 10. +- **Deterministic execution**: All operations (render, input, audio) must be reproducible given the same scenario, seed, and scripted inputs. +- **Call recording architecture**: Both HeadlessAdapter and TestAdapter record all relevant provider calls for later inspection and assertion. Recording uses in-memory lists bounded by scenario size. +- **Separation of concerns**: Each provider (render, input, UI, asset, audio) is independently testable and replaceable. +- **CI compatibility**: The adapters must run in CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware. + +## Implementation guide + +### Plan requirements + +- (***Complete***) Headless adapter passes integration scenarios for AI, combat and map generation. + - GIVEN deterministic seeds and scenario scripts + - WHEN CI runs the integration suite + - THEN results are stable and asserted by tests. + +### Phase 1 — HeadlessAdapter core + +***Complete*** + +#### Objective + +Implement a `HeadlessAdapter` and its providers that simulate all engine operations without any platform dependencies, recording render and input operations for verification. Success criteria: the adapter implements all Phase 1 contracts and can run a complete simulation loop without native dependencies. + +#### Technical details + +- **HeadlessAdapter**: Implements `IEngineAdapter`. Composes headless providers for rendering, input, UI, assets, and audio. Returns `HeadlessEngineCapabilities` from the `Capabilities` property. +- **HeadlessRenderProvider**: Implements `IRenderProvider`. Records all render commands (`SubmitSprite`, `SubmitText`, `SubmitMesh`) to an in-memory list for later inspection. `BeginFrame` returns a `FrameScope`; `EndFrame` and `Present` are no-ops. +- **HeadlessInputProvider**: Implements `IInputProvider`. Simulates input via a queue of scripted events, enabling deterministic input playback. Events are consumed in order per tick. +- **HeadlessUserInterfaceProvider**: Implements `IUserInterfaceProvider`. Executes UI layout and hit-testing logic without rendering any visuals. +- **HeadlessAssetProvider**: Implements `IAssetProvider`. Manages asset cache and lifecycle state in memory without actual file or resource access. Composes a `HeadlessAssetLoader` (implementing `IAssetLoader`) that returns stub assets for any requested identifier. +- **HeadlessAudioPlayer**: Implements `IAudioPlayer`. All methods are no-ops or record calls for later verification. + +#### Phase requirements + +- (***Complete***) HeadlessAdapter implements all Phase 1 contracts + - GIVEN the Phase 1 interfaces and DTOs are defined + - WHEN HeadlessAdapter is instantiated with an `EngineConfig` + - THEN all provider accessors return functional headless implementations. + +- (***Complete***) Render command recording + - GIVEN a HeadlessRenderProvider + - WHEN sprites, text, and meshes are submitted + - THEN all commands are recorded in order and retrievable for assertion. + +- (***Complete***) Scripted input playback + - GIVEN a HeadlessInputProvider initialized with scripted events + - WHEN input is polled + - THEN events are returned in the scripted order. + +#### Examples + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Simulates engine operations for headless, deterministic testing. +/// Records all provider calls for verification. +/// +public sealed class HeadlessAdapter : IEngineAdapter +{ + /// Gets the headless engine capabilities. + public EngineCapabilities Capabilities { get; } + + /// Gets the configuration used to initialize this adapter. + public EngineConfig Config { get; } + + /// + public IRenderProvider RenderProvider { get; } + + /// + public IInputProvider InputProvider { get; } + + /// + public IUserInterfaceProvider UserInterfaceProvider { get; } + + /// + public IAssetProvider AssetProvider { get; } + + /// + public IAudioPlayer AudioPlayer { get; } + + /// + /// Initializes a new headless adapter with the specified configuration. + /// + /// Adapter configuration. + public HeadlessAdapter(EngineConfig config) + { + Config = config; + Capabilities = new EngineCapabilities( + Supports2D: true, + Supports3D: false, + SupportsShaders: false, + SupportsAudio: false, + SupportsRichUI: false, + ContractVersion: "1.0.0", + SupportedTextureFormats: [], + MaxTextureSize: 0, + MaxAudioChannels: 0); + RenderProvider = new HeadlessRenderProvider(); + InputProvider = new HeadlessInputProvider(); + UserInterfaceProvider = new HeadlessUserInterfaceProvider(); + AssetProvider = new HeadlessAssetProvider(); + AudioPlayer = new HeadlessAudioPlayer(); + } + + /// + public Task InitializeAsync(EngineConfig config, CancellationToken ct = default) + => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken ct = default) + => Task.CompletedTask; + + /// + public void Dispose() { /* No resources to release */ } +} +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Records render commands for verification without GPU interaction. +/// +public sealed class HeadlessRenderProvider : IRenderProvider +{ + private readonly List _recordedCommands = []; + + /// Gets the list of recorded render commands. + public IReadOnlyList RecordedCommands => _recordedCommands; + + /// + public FrameScope BeginFrame(in CameraDescriptor camera) => new(); + + /// + public void SubmitSprite(in SpriteDrawDto dto) => _recordedCommands.Add(dto); + + /// + public void SubmitText(in TextDrawDto dto) => _recordedCommands.Add(dto); + + /// + public void SubmitMesh(in MeshDrawDto dto) => _recordedCommands.Add(dto); + + /// + public void EndFrame() { } + + /// + public void Present() { } + + /// Clears all recorded commands. + public void Clear() => _recordedCommands.Clear(); +} +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Simulates input via a queue of scripted events for deterministic playback. +/// +public sealed class HeadlessInputProvider : IInputProvider +{ + private readonly HashSet _keysDown = []; + private readonly HashSet _buttonsDown = []; + private (float X, float Y) _mousePosition; + + /// + /// Enqueues a key-down event for the specified key. + /// + /// The key identifier. + public void ScriptKeyDown(string key) => _keysDown.Add(key); + + /// + /// Enqueues a key-up event for the specified key. + /// + /// The key identifier. + public void ScriptKeyUp(string key) => _keysDown.Remove(key); + + /// + /// Enqueues a mouse button down event. + /// + /// The mouse button index. + public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button); + + /// + /// Enqueues a mouse button up event. + /// + /// The mouse button index. + public void ScriptMouseButtonUp(int button) => _buttonsDown.Remove(button); + + /// + /// Sets the scripted mouse position. + /// + /// X coordinate in screen space. + /// Y coordinate in screen space. + public void ScriptMousePosition(float x, float y) => _mousePosition = (x, y); + + /// + public bool IsKeyDown(string key) => _keysDown.Contains(key); + + /// + public bool IsMouseButtonDown(int button) => _buttonsDown.Contains(button); + + /// + public (float X, float Y) GetMousePosition() => _mousePosition; + + /// Resets all scripted input state. + public void Reset() + { + _keysDown.Clear(); + _buttonsDown.Clear(); + _mousePosition = (0f, 0f); + } +} +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Headless implementation of . +/// Executes UI layout and hit-testing logic without rendering any visuals. +/// +public sealed class HeadlessUserInterfaceProvider : IUserInterfaceProvider +{ + // Members to be defined when IUserInterfaceProvider is finalized. +} +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Manages asset cache and lifecycle state in memory without actual file or resource access. +/// Composes a for stub asset loading. +/// +public sealed class HeadlessAssetProvider : IAssetProvider +{ + private readonly Dictionary _cache = []; + + /// + /// Initializes a new with a default + /// . + /// + public HeadlessAssetProvider() + { + Loader = new HeadlessAssetLoader(); + } + + /// + public IAssetLoader Loader { get; } + + /// + public object? GetAsset(string assetId) => + _cache.TryGetValue(assetId, out var asset) ? asset : null; + + /// + public void UnloadAsset(string assetId) => _cache.Remove(assetId); + + /// + public bool IsAssetLoaded(string assetId) => _cache.ContainsKey(assetId); + + /// + public void ClearCache() => _cache.Clear(); + + /// + /// Loads an asset via the loader and caches it. + /// + /// Asset identifier to load and cache. + public void LoadAndCache(string assetId) + { + if (!_cache.ContainsKey(assetId)) + { + _cache[assetId] = Loader.Load(assetId); + } + } +} +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Stub asset loader that returns placeholder objects for any requested identifier. +/// +public sealed class HeadlessAssetLoader : IAssetLoader +{ + /// + public Task LoadAsync(string assetId, CancellationToken ct = default) => + Task.FromResult(new object()); + + /// + public object Load(string assetId) => new object(); +} +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Headless audio player that records all calls for verification. +/// All playback methods are no-ops; calls are recorded for test assertion. +/// +public sealed class HeadlessAudioPlayer : IAudioPlayer +{ + private readonly List<(string Method, string AudioAssetId, object? Arg)> _recordedCalls = []; + + /// Gets the list of recorded audio calls for test assertion. + public IReadOnlyList<(string Method, string AudioAssetId, object? Arg)> RecordedCalls => + _recordedCalls; + + /// + public void Play(string audioAssetId, bool loop = false) => + _recordedCalls.Add(("Play", audioAssetId, loop)); + + /// + public void Stop(string audioAssetId) => + _recordedCalls.Add(("Stop", audioAssetId, null)); + + /// + public void SetVolume(string audioAssetId, float volume) => + _recordedCalls.Add(("SetVolume", audioAssetId, volume)); + + /// Clears all recorded calls. + public void Clear() => _recordedCalls.Clear(); +} +``` + +### Phase 2 — TestAdapter for CI + +***Complete*** + +#### Objective + +Provide a `TestAdapter` that wraps the HeadlessAdapter, intercepting and recording all method calls and parameters for assertion and verification in CI environments. + +#### Technical details + +- **TestAdapter**: Implements `IEngineAdapter`. Composes a `HeadlessAdapter` internally and wraps each provider with a recording decorator. +- **Recording decorators**: Each provider is wrapped with a decorator that logs all method calls and their arguments to a shared in-memory list before delegating to the inner provider. +- **Assertion API**: Exposes `RecordedCalls` for test code to inspect and assert the sequence and arguments of adapter interactions. + +#### Phase requirements + +- (***Complete***) TestAdapter records all adapter calls + - GIVEN a TestAdapter wrapping a HeadlessAdapter + - WHEN any provider method is called + - THEN the call and its arguments are recorded for assertion. + +- (***Complete***) TestAdapter supports call assertion + - GIVEN a test scenario and expected sequence of adapter calls + - WHEN the test completes + - THEN recorded calls can be queried and asserted for correctness. + +#### Examples + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +/// +/// DTO recording a single adapter call for test assertion. +/// +/// Name of the provider (e.g. "Render", "Input"). +/// Name of the method called. +/// Arguments passed to the method. +public readonly record struct RecordedCall( + string ProviderName, + string MethodName, + IReadOnlyList Arguments); +``` + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Adapter for CI that wraps HeadlessAdapter and records all calls for assertion. +/// +public sealed class TestAdapter : IEngineAdapter +{ + private readonly HeadlessAdapter _inner; + private readonly List _recordedCalls = []; + + /// Gets the recorded calls for assertion. + public IReadOnlyList RecordedCalls => _recordedCalls; + + /// + public EngineCapabilities Capabilities => _inner.Capabilities; + + /// + public IRenderProvider RenderProvider => _inner.RenderProvider; + + /// + public IInputProvider InputProvider => _inner.InputProvider; + + /// + public IUserInterfaceProvider UserInterfaceProvider => _inner.UserInterfaceProvider; + + /// + public IAssetProvider AssetProvider => _inner.AssetProvider; + + /// + public IAudioPlayer AudioPlayer => _inner.AudioPlayer; + + /// + /// Initializes a new test adapter wrapping a headless adapter. + /// + /// Adapter configuration. + public TestAdapter(EngineConfig config) + { + _inner = new HeadlessAdapter(config); + } + + /// + public Task InitializeAsync(EngineConfig config, CancellationToken ct = default) + { + _recordedCalls.Add(new RecordedCall("Adapter", "InitializeAsync", [config])); + return _inner.InitializeAsync(config, ct); + } + + /// + public Task ShutdownAsync(CancellationToken ct = default) + { + _recordedCalls.Add(new RecordedCall("Adapter", "ShutdownAsync", [])); + return _inner.ShutdownAsync(ct); + } + + /// + public void Dispose() => _inner.Dispose(); +} +``` + +### Phase 3 — Deterministic execution + +***Complete*** + +#### Objective + +Ensure all simulation and engine operations are deterministic, supporting fixed timestep updates and seeded random number generation for reproducible test runs. Success criteria: running the same scenario with the same seed produces identical render command sequences and state transitions. + +#### Technical details + +- **Fixed timestep**: The adapter advances simulation by a constant interval each tick, avoiding variable frame rate effects. The timestep is configurable at initialization. +- **Seeded RNG**: All random operations use a provided seed, ensuring identical results for the same scenario. The RNG is exposed via a shared service or passed to providers. +- **Scenario scripting**: Input and event scripts are replayed identically across runs. The HeadlessInputProvider consumes events from a pre-built queue. + +#### Phase requirements + +- (***Complete***) Fixed timestep execution + - GIVEN a configured fixed timestep interval + - WHEN the simulation runs N steps + - THEN each step advances by exactly the configured interval. + +- (***Complete***) Seeded RNG reproducibility + - GIVEN a fixed RNG seed + - WHEN the simulation runs twice with identical inputs + - THEN the results (render commands, state transitions) are identical. + +#### Examples + +```csharp +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Runs a headless simulation with deterministic fixed timestep and seeded RNG. +/// +public sealed class DeterministicEngineRunner +{ + private readonly HeadlessAdapter _adapter; + private readonly Random _rng; + private readonly TimeSpan _fixedTimestep; + + /// + /// Initializes a new deterministic engine runner. + /// + /// The headless adapter to drive. + /// RNG seed for reproducibility. + /// Time interval per simulation step. + public DeterministicEngineRunner(HeadlessAdapter adapter, int seed, TimeSpan fixedTimestep) + { + _adapter = adapter; + _rng = new Random(seed); + _fixedTimestep = fixedTimestep; + } + + /// + /// Runs the simulation for the specified number of steps. + /// + /// Number of simulation steps to execute. + public void Run(int steps) + { + var renderProvider = _adapter.RenderProvider; + var camera = new CameraDescriptor( + ProjectionType.Orthographic, 0f, 10f, 16f / 9f, 0.1f, 100f); + + for (var i = 0; i < steps; i++) + { + using var scope = renderProvider.BeginFrame(in camera); + // Simulation logic using _rng for determinism + renderProvider.EndFrame(); + renderProvider.Present(); + } + } +} +``` + +### Testing and compatibility + +- All adapters must be covered by integration tests for AI, combat, and map generation scenarios. +- Tests must assert that, given the same seed and scripted events, results are stable and reproducible across runs. +- Adapters must be compatible with CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware. +- TestAdapter must expose APIs for retrieving and asserting recorded calls. +- Unit tests should cover each headless provider independently. + +### Performance targets + +- HeadlessAdapter and TestAdapter must complete integration scenarios in under 100ms per scenario on typical CI hardware. +- No allocations or operations that would cause test flakiness or non-determinism. +- Call recording memory usage bounded by scenario size; consider clearing between scenarios. + +## Known issues and design concerns + +- **Phase 1 dependency**: Implementation cannot proceed until all Phase 1 interfaces and DTOs are finalized. Changes to Phase 1 contracts will require updates to HeadlessAdapter and TestAdapter. +- **Scenario scripting format**: The input scripting format and playback mechanism must be well-defined before HeadlessInputProvider can be fully implemented. Consider a simple queue-based approach initially. +- **Call recording overhead**: Recording all calls may impact performance for very large scenarios. Consider providing a flag to disable recording or bounding the recording buffer. + +## See also + +- [Phase 1 — Interface development](./phase-1-interface-development.md) +- Parent plan: [Engine Decoupling](https://github.com/JohnLudlow/FourXGame/blob/main/docs/plans/4x-game/technical/engine-decoupling/engine-decoupling.md) + +## References + +- [Phase 2 issue #5](https://github.com/JohnLudlow/GameEngineAdapter/issues/5) +- [Phase 1 issue #1](https://github.com/JohnLudlow/GameEngineAdapter/issues/1) diff --git a/src/GameEngineAdapter.Core/CameraDescriptor.cs b/src/GameEngineAdapter.Core/CameraDescriptor.cs new file mode 100644 index 0000000..b8176d9 --- /dev/null +++ b/src/GameEngineAdapter.Core/CameraDescriptor.cs @@ -0,0 +1,23 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Defines the type of camera projection. +/// +public enum ProjectionType { Orthographic, Perspective } + +/// +/// Describes camera configuration for a render frame. +/// +/// The projection model to use. +/// Field of view in degrees (for perspective projection). +/// Size of the orthographic view (for orthographic projection). +/// Aspect ratio of the view. +/// Distance to the near clipping plane. +/// Distance to the far clipping plane. +public readonly record struct CameraDescriptor( + ProjectionType Projection, + float FieldOfViewDegrees, + float OrthographicSize, + float AspectRatio, + float NearPlane, + float FarPlane); diff --git a/src/GameEngineAdapter.Core/EngineCapabilities.cs b/src/GameEngineAdapter.Core/EngineCapabilities.cs new file mode 100644 index 0000000..95c4268 --- /dev/null +++ b/src/GameEngineAdapter.Core/EngineCapabilities.cs @@ -0,0 +1,25 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Describes the capabilities and features supported by an engine adapter. +/// +/// Indicates if 2D rendering is supported. +/// Indicates if 3D rendering is supported. +/// Indicates if custom shaders are supported. +/// Indicates if audio playback is supported. +/// Indicates if rich UI features are supported. +/// The semantic version of the adapter contract. +/// List of texture formats supported by the engine. +/// Maximum supported texture size in pixels. +/// Maximum number of concurrent audio channels. +public readonly record struct EngineCapabilities( + bool Supports2D, + bool Supports3D, + bool SupportsShaders, + bool SupportsAudio, + bool SupportsRichUI, + string ContractVersion, + IReadOnlyList SupportedTextureFormats, + int MaxTextureSize, + int MaxAudioChannels); + diff --git a/src/GameEngineAdapter/EngineConfig.cs b/src/GameEngineAdapter.Core/EngineConfig.cs similarity index 91% rename from src/GameEngineAdapter/EngineConfig.cs rename to src/GameEngineAdapter.Core/EngineConfig.cs index c79fae4..d6c5a86 100644 --- a/src/GameEngineAdapter/EngineConfig.cs +++ b/src/GameEngineAdapter.Core/EngineConfig.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// diff --git a/src/GameEngineAdapter/FrameScope.cs b/src/GameEngineAdapter.Core/FrameScope.cs similarity index 86% rename from src/GameEngineAdapter/FrameScope.cs rename to src/GameEngineAdapter.Core/FrameScope.cs index 7df42ef..685e4e7 100644 --- a/src/GameEngineAdapter/FrameScope.cs +++ b/src/GameEngineAdapter.Core/FrameScope.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// /// Disposable scope for a single render frame. Disposing finalizes the frame. diff --git a/src/GameEngineAdapter/GameEngineAdapter.csproj b/src/GameEngineAdapter.Core/GameEngineAdapter.Core.csproj similarity index 75% rename from src/GameEngineAdapter/GameEngineAdapter.csproj rename to src/GameEngineAdapter.Core/GameEngineAdapter.Core.csproj index 87238be..26007d5 100644 --- a/src/GameEngineAdapter/GameEngineAdapter.csproj +++ b/src/GameEngineAdapter.Core/GameEngineAdapter.Core.csproj @@ -5,7 +5,7 @@ enable enable - JohnLudlow.GameEngineAdapter + JohnLudlow.GameEngineAdapter.Core diff --git a/src/GameEngineAdapter.Core/HeadlessEngineCapabilities.cs b/src/GameEngineAdapter.Core/HeadlessEngineCapabilities.cs new file mode 100644 index 0000000..e0cb21e --- /dev/null +++ b/src/GameEngineAdapter.Core/HeadlessEngineCapabilities.cs @@ -0,0 +1,12 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Capabilities variant for headless or test-oriented adapters. +/// +/// Base engine capabilities. +/// Indicates if offscreen rendering/capture is supported. +/// Indicates if the engine supports a deterministic update tick. +public readonly record struct HeadlessEngineCapabilities( + EngineCapabilities Base, + bool SupportsOffscreenRendering, + bool DeterministicTick); diff --git a/src/GameEngineAdapter.Core/IAssetLoader.cs b/src/GameEngineAdapter.Core/IAssetLoader.cs new file mode 100644 index 0000000..2cf32d3 --- /dev/null +++ b/src/GameEngineAdapter.Core/IAssetLoader.cs @@ -0,0 +1,14 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Interface for loading assets from storage (disk, network, embedded resources). +/// Separate from which handles caching and lifecycle. +/// +public interface IAssetLoader +{ + /// Asynchronously loads an asset by identifier. + Task LoadAsync(string assetId, CancellationToken ct = default); + + /// Synchronously loads an asset by identifier. + object Load(string assetId); +} diff --git a/src/GameEngineAdapter.Core/IAssetProvider.cs b/src/GameEngineAdapter.Core/IAssetProvider.cs new file mode 100644 index 0000000..d6da5a0 --- /dev/null +++ b/src/GameEngineAdapter.Core/IAssetProvider.cs @@ -0,0 +1,23 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Adapter-owned provider for asset lifecycle management, caching, and querying. +/// Does not load assets directly — delegates to for I/O. +/// +public interface IAssetProvider +{ + /// Returns the asset loader used by this provider. + IAssetLoader Loader { get; } + + /// Returns a previously loaded asset by identifier, or null if not cached. + object? GetAsset(string assetId); + + /// Unloads a previously loaded asset and removes it from the cache. + void UnloadAsset(string assetId); + + /// Returns true if the specified asset is currently loaded and cached. + bool IsAssetLoaded(string assetId); + + /// Evicts all cached assets. + void ClearCache(); +} diff --git a/src/GameEngineAdapter.Core/IAudioPlayer.cs b/src/GameEngineAdapter.Core/IAudioPlayer.cs new file mode 100644 index 0000000..3084e85 --- /dev/null +++ b/src/GameEngineAdapter.Core/IAudioPlayer.cs @@ -0,0 +1,16 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Interface for audio playback (play, stop, volume control). +/// +public interface IAudioPlayer +{ + /// Plays the specified audio asset. + void Play(string audioAssetId, bool loop = false); + + /// Stops playback of the specified audio asset. + void Stop(string audioAssetId); + + /// Sets the volume for the specified audio asset (0.0–1.0). + void SetVolume(string audioAssetId, float volume); +} diff --git a/src/GameEngineAdapter.Core/IEngineAdapter.cs b/src/GameEngineAdapter.Core/IEngineAdapter.cs new file mode 100644 index 0000000..1bb7a00 --- /dev/null +++ b/src/GameEngineAdapter.Core/IEngineAdapter.cs @@ -0,0 +1,31 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Root interface for an engine adapter. +/// +public interface IEngineAdapter : IDisposable +{ + /// Gets the advertised capabilities of the engine adapter. + EngineCapabilities Capabilities { get; } + + /// Initializes the engine adapter with the specified configuration. + Task InitializeAsync(EngineConfig config, CancellationToken ct = default); + + /// Shuts down the engine adapter and releases resources. + Task ShutdownAsync(CancellationToken ct = default); + + /// Gets the provider for rendering operations. + IRenderProvider RenderProvider { get; } + + /// Gets the provider for polling input state. + IInputProvider InputProvider { get; } + + /// Gets the provider for user interface operations. + IUserInterfaceProvider UserInterfaceProvider { get; } + + /// Gets the provider for asset management. + IAssetProvider AssetProvider { get; } + + /// Gets the provider for audio playback. + IAudioPlayer AudioPlayer { get; } +} diff --git a/src/GameEngineAdapter.Core/IInputProvider.cs b/src/GameEngineAdapter.Core/IInputProvider.cs new file mode 100644 index 0000000..5e58411 --- /dev/null +++ b/src/GameEngineAdapter.Core/IInputProvider.cs @@ -0,0 +1,16 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Adapter-owned provider for polling input state (keyboard, mouse, gamepad). +/// +public interface IInputProvider +{ + /// Returns true if the specified key is currently pressed. + bool IsKeyDown(string key); + + /// Returns true if the specified mouse button is currently pressed. + bool IsMouseButtonDown(int button); + + /// Returns the current mouse position in screen coordinates. + (float X, float Y) GetMousePosition(); +} diff --git a/src/GameEngineAdapter.Core/IRenderProvider.cs b/src/GameEngineAdapter.Core/IRenderProvider.cs new file mode 100644 index 0000000..76327a0 --- /dev/null +++ b/src/GameEngineAdapter.Core/IRenderProvider.cs @@ -0,0 +1,29 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Provider for rendering operations and submitting draw commands. +/// +public interface IRenderProvider +{ + /// + /// Starts a new render frame with the specified camera configuration. + /// + /// Camera configuration for the frame. + /// A scope that finalizes the frame when disposed. + FrameScope BeginFrame(in CameraDescriptor camera); + + /// Submits a sprite for rendering. + void SubmitSprite(in SpriteDrawDto dto); + + /// Submits text for rendering. + void SubmitText(in TextDrawDto dto); + + /// Submits a mesh for rendering. + void SubmitMesh(in MeshDrawDto dto); + + /// Ends the current render frame. + void EndFrame(); + + /// Presents the rendered frame to the display. + void Present(); +} diff --git a/src/GameEngineAdapter.Core/IUserInterfaceProvider.cs b/src/GameEngineAdapter.Core/IUserInterfaceProvider.cs new file mode 100644 index 0000000..4568511 --- /dev/null +++ b/src/GameEngineAdapter.Core/IUserInterfaceProvider.cs @@ -0,0 +1,9 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Adapter-owned provider for user interface operations. +/// +public interface IUserInterfaceProvider +{ + // Members to be defined based on UI requirements. +} diff --git a/src/GameEngineAdapter/MaterialDto.cs b/src/GameEngineAdapter.Core/MaterialDto.cs similarity index 90% rename from src/GameEngineAdapter/MaterialDto.cs rename to src/GameEngineAdapter.Core/MaterialDto.cs index 0d2258e..8abf7fa 100644 --- a/src/GameEngineAdapter/MaterialDto.cs +++ b/src/GameEngineAdapter.Core/MaterialDto.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// /// Data transfer object for material configuration. diff --git a/src/GameEngineAdapter/MeshDrawDto.cs b/src/GameEngineAdapter.Core/MeshDrawDto.cs similarity index 91% rename from src/GameEngineAdapter/MeshDrawDto.cs rename to src/GameEngineAdapter.Core/MeshDrawDto.cs index a2b956b..535c82e 100644 --- a/src/GameEngineAdapter/MeshDrawDto.cs +++ b/src/GameEngineAdapter.Core/MeshDrawDto.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// /// DTO for submitting a mesh draw command. diff --git a/src/GameEngineAdapter/SpriteDrawDto.cs b/src/GameEngineAdapter.Core/SpriteDrawDto.cs similarity index 91% rename from src/GameEngineAdapter/SpriteDrawDto.cs rename to src/GameEngineAdapter.Core/SpriteDrawDto.cs index f1eaa15..992ca44 100644 --- a/src/GameEngineAdapter/SpriteDrawDto.cs +++ b/src/GameEngineAdapter.Core/SpriteDrawDto.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// /// DTO for submitting a sprite draw command. diff --git a/src/GameEngineAdapter/TextDrawDto.cs b/src/GameEngineAdapter.Core/TextDrawDto.cs similarity index 93% rename from src/GameEngineAdapter/TextDrawDto.cs rename to src/GameEngineAdapter.Core/TextDrawDto.cs index bf325b6..d6f4ac6 100644 --- a/src/GameEngineAdapter/TextDrawDto.cs +++ b/src/GameEngineAdapter.Core/TextDrawDto.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// /// DTO for submitting a text draw command. diff --git a/src/GameEngineAdapter/TransformDto.cs b/src/GameEngineAdapter.Core/TransformDto.cs similarity index 94% rename from src/GameEngineAdapter/TransformDto.cs rename to src/GameEngineAdapter.Core/TransformDto.cs index 6f9c4c7..728da52 100644 --- a/src/GameEngineAdapter/TransformDto.cs +++ b/src/GameEngineAdapter.Core/TransformDto.cs @@ -1,4 +1,4 @@ -namespace JohnLudlow.GameEngineAdapter; +namespace JohnLudlow.GameEngineAdapter.Core; /// /// World-space transform for an object. diff --git a/src/GameEngineAdapter.Headless/DeterministicEngineRunner.cs b/src/GameEngineAdapter.Headless/DeterministicEngineRunner.cs new file mode 100644 index 0000000..9be0830 --- /dev/null +++ b/src/GameEngineAdapter.Headless/DeterministicEngineRunner.cs @@ -0,0 +1,45 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Runs a headless simulation with deterministic fixed timestep and seeded RNG. +/// +public sealed class DeterministicEngineRunner +{ + private readonly HeadlessAdapter _adapter; + private readonly Random _rng; + private readonly TimeSpan _fixedTimestep; + + /// + /// Initializes a new deterministic engine runner. + /// + /// The headless adapter to drive. + /// RNG seed for reproducibility. + /// Time interval per simulation step. + public DeterministicEngineRunner(HeadlessAdapter adapter, int seed, TimeSpan fixedTimestep) + { + _adapter = adapter; + _rng = new Random(seed); + _fixedTimestep = fixedTimestep; + } + + /// + /// Runs the simulation for the specified number of steps. + /// + /// Number of simulation steps to execute. + public void Run(int steps) + { + var renderProvider = _adapter.RenderProvider; + var camera = new CameraDescriptor( + ProjectionType.Orthographic, 0f, 10f, 16f / 9f, 0.1f, 100f); + + for (var i = 0; i < steps; i++) + { + using var scope = renderProvider.BeginFrame(in camera); + // Simulation logic using _rng for determinism + renderProvider.EndFrame(); + renderProvider.Present(); + } + } +} diff --git a/src/GameEngineAdapter.Headless/GameEngineAdapter.Headless.csproj b/src/GameEngineAdapter.Headless/GameEngineAdapter.Headless.csproj new file mode 100644 index 0000000..95882a5 --- /dev/null +++ b/src/GameEngineAdapter.Headless/GameEngineAdapter.Headless.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + JohnLudlow.GameEngineAdapter.Headless + + + + + + + diff --git a/src/GameEngineAdapter.Headless/HeadlessAdapter.cs b/src/GameEngineAdapter.Headless/HeadlessAdapter.cs new file mode 100644 index 0000000..46bb96c --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessAdapter.cs @@ -0,0 +1,66 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Simulates engine operations for headless, deterministic testing. +/// Records all provider calls for verification. +/// +public sealed class HeadlessAdapter : IEngineAdapter +{ + /// Gets the headless engine capabilities. + public EngineCapabilities Capabilities { get; } + + /// Gets the configuration used to initialize this adapter. + public EngineConfig Config { get; } + + /// + public IRenderProvider RenderProvider { get; } + + /// + public IInputProvider InputProvider { get; } + + /// + public IUserInterfaceProvider UserInterfaceProvider { get; } + + /// + public IAssetProvider AssetProvider { get; } + + /// + public IAudioPlayer AudioPlayer { get; } + + /// + /// Initializes a new headless adapter with the specified configuration. + /// + /// Adapter configuration. + public HeadlessAdapter(EngineConfig config) + { + Config = config; + Capabilities = new EngineCapabilities( + Supports2D: true, + Supports3D: false, + SupportsShaders: false, + SupportsAudio: false, + SupportsRichUI: false, + ContractVersion: "1.0.0", + SupportedTextureFormats: [], + MaxTextureSize: 0, + MaxAudioChannels: 0); + RenderProvider = new HeadlessRenderProvider(); + InputProvider = new HeadlessInputProvider(); + UserInterfaceProvider = new HeadlessUserInterfaceProvider(); + AssetProvider = new HeadlessAssetProvider(); + AudioPlayer = new HeadlessAudioPlayer(); + } + + /// + public Task InitializeAsync(EngineConfig config, CancellationToken ct = default) + => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken ct = default) + => Task.CompletedTask; + + /// + public void Dispose() { /* No resources to release */ } +} diff --git a/src/GameEngineAdapter.Headless/HeadlessAssetLoader.cs b/src/GameEngineAdapter.Headless/HeadlessAssetLoader.cs new file mode 100644 index 0000000..58963bb --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessAssetLoader.cs @@ -0,0 +1,16 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Stub asset loader that returns placeholder objects for any requested identifier. +/// +public sealed class HeadlessAssetLoader : IAssetLoader +{ + /// + public Task LoadAsync(string assetId, CancellationToken ct = default) => + Task.FromResult(new object()); + + /// + public object Load(string assetId) => new(); +} diff --git a/src/GameEngineAdapter.Headless/HeadlessAssetProvider.cs b/src/GameEngineAdapter.Headless/HeadlessAssetProvider.cs new file mode 100644 index 0000000..b8d498a --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessAssetProvider.cs @@ -0,0 +1,49 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Manages asset cache and lifecycle state in memory without actual file or resource access. +/// Composes a for stub asset loading. +/// +public sealed class HeadlessAssetProvider : IAssetProvider +{ + private readonly Dictionary _cache = []; + + /// + /// Initializes a new with a default + /// . + /// + public HeadlessAssetProvider() + { + Loader = new HeadlessAssetLoader(); + } + + /// + public IAssetLoader Loader { get; } + + /// + public object? GetAsset(string assetId) => + _cache.TryGetValue(assetId, out var asset) ? asset : null; + + /// + public void UnloadAsset(string assetId) => _cache.Remove(assetId); + + /// + public bool IsAssetLoaded(string assetId) => _cache.ContainsKey(assetId); + + /// + public void ClearCache() => _cache.Clear(); + + /// + /// Loads an asset via the loader and caches it. + /// + /// Asset identifier to load and cache. + public void LoadAndCache(string assetId) + { + if (!_cache.ContainsKey(assetId)) + { + _cache[assetId] = Loader.Load(assetId); + } + } +} diff --git a/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs b/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs new file mode 100644 index 0000000..c0475c1 --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs @@ -0,0 +1,31 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Headless audio player that records all calls for verification. +/// All playback methods are no-ops; calls are recorded for test assertion. +/// +public sealed class HeadlessAudioPlayer : IAudioPlayer +{ + private readonly List<(string Method, string AudioAssetId, object? Arg)> _recordedCalls = []; + + /// Gets the list of recorded audio calls for test assertion. + public IReadOnlyList<(string Method, string AudioAssetId, object? Arg)> RecordedCalls => + _recordedCalls; + + /// + public void Play(string audioAssetId, bool loop = false) => + _recordedCalls.Add(("Play", audioAssetId, loop)); + + /// + public void Stop(string audioAssetId) => + _recordedCalls.Add(("Stop", audioAssetId, null)); + + /// + public void SetVolume(string audioAssetId, float volume) => + _recordedCalls.Add(("SetVolume", audioAssetId, volume)); + + /// Clears all recorded calls. + public void Clear() => _recordedCalls.Clear(); +} diff --git a/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs b/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs new file mode 100644 index 0000000..d3d58a9 --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs @@ -0,0 +1,61 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Simulates input via a queue of scripted events for deterministic playback. +/// +public sealed class HeadlessInputProvider : IInputProvider +{ + private readonly HashSet _keysDown = []; + private readonly HashSet _buttonsDown = []; + private (float X, float Y) _mousePosition; + + /// + /// Enqueues a key-down event for the specified key. + /// + /// The key identifier. + public void ScriptKeyDown(string key) => _keysDown.Add(key); + + /// + /// Enqueues a key-up event for the specified key. + /// + /// The key identifier. + public void ScriptKeyUp(string key) => _keysDown.Remove(key); + + /// + /// Enqueues a mouse button down event. + /// + /// The mouse button index. + public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button); + + /// + /// Enqueues a mouse button up event. + /// + /// The mouse button index. + public void ScriptMouseButtonUp(int button) => _buttonsDown.Remove(button); + + /// + /// Sets the scripted mouse position. + /// + /// X coordinate in screen space. + /// Y coordinate in screen space. + public void ScriptMousePosition(float x, float y) => _mousePosition = (x, y); + + /// + public bool IsKeyDown(string key) => _keysDown.Contains(key); + + /// + public bool IsMouseButtonDown(int button) => _buttonsDown.Contains(button); + + /// + public (float X, float Y) GetMousePosition() => _mousePosition; + + /// Resets all scripted input state. + public void Reset() + { + _keysDown.Clear(); + _buttonsDown.Clear(); + _mousePosition = (0f, 0f); + } +} diff --git a/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs b/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs new file mode 100644 index 0000000..5ff3e2d --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs @@ -0,0 +1,35 @@ +using JohnLudlow.GameEngineAdapter.Core; + +namespace JohnLudlow.GameEngineAdapter.Headless; + +/// +/// Records render commands for verification without GPU interaction. +/// +public sealed class HeadlessRenderProvider : IRenderProvider +{ + private readonly List _recordedCommands = []; + + /// Gets the list of recorded render commands. + public IReadOnlyList RecordedCommands => _recordedCommands; + + /// + public FrameScope BeginFrame(in CameraDescriptor camera) => new(); + + /// + public void SubmitSprite(in SpriteDrawDto dto) => _recordedCommands.Add(dto); + + /// + public void SubmitText(in TextDrawDto dto) => _recordedCommands.Add(dto); + + /// + public void SubmitMesh(in MeshDrawDto dto) => _recordedCommands.Add(dto); + + /// + public void EndFrame() { } + + /// + public void Present() { } + + /// Clears all recorded commands. + public void Clear() => _recordedCommands.Clear(); +} \ No newline at end of file diff --git a/src/GameEngineAdapter.Headless/HeadlessUserInterfaceProvider.cs b/src/GameEngineAdapter.Headless/HeadlessUserInterfaceProvider.cs new file mode 100644 index 0000000..3021173 --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessUserInterfaceProvider.cs @@ -0,0 +1,12 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Headless implementation of . +/// Executes UI layout and hit-testing logic without rendering any visuals. +/// +public sealed class HeadlessUserInterfaceProvider : IUserInterfaceProvider +{ + // Members to be defined when IUserInterfaceProvider is finalized. +} diff --git a/src/GameEngineAdapter.Headless/RecordedCall.cs b/src/GameEngineAdapter.Headless/RecordedCall.cs new file mode 100644 index 0000000..269faba --- /dev/null +++ b/src/GameEngineAdapter.Headless/RecordedCall.cs @@ -0,0 +1,12 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +/// +/// DTO recording a single adapter call for test assertion. +/// +/// Name of the provider (e.g. "Render", "Input"). +/// Name of the method called. +/// Arguments passed to the method. +public readonly record struct RecordedCall( + string ProviderName, + string MethodName, + IReadOnlyList Arguments); diff --git a/src/GameEngineAdapter.Headless/TestAdapter.cs b/src/GameEngineAdapter.Headless/TestAdapter.cs new file mode 100644 index 0000000..a170848 --- /dev/null +++ b/src/GameEngineAdapter.Headless/TestAdapter.cs @@ -0,0 +1,59 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Adapter for CI that wraps HeadlessAdapter and records all calls for assertion. +/// +public sealed class TestAdapter : IEngineAdapter +{ + private readonly HeadlessAdapter _inner; + private readonly List _recordedCalls = []; + + /// Gets the recorded calls for assertion. + public IReadOnlyList RecordedCalls => _recordedCalls; + + /// + public EngineCapabilities Capabilities => _inner.Capabilities; + + /// + public IRenderProvider RenderProvider => _inner.RenderProvider; + + /// + public IInputProvider InputProvider => _inner.InputProvider; + + /// + public IUserInterfaceProvider UserInterfaceProvider => _inner.UserInterfaceProvider; + + /// + public IAssetProvider AssetProvider => _inner.AssetProvider; + + /// + public IAudioPlayer AudioPlayer => _inner.AudioPlayer; + + /// + /// Initializes a new test adapter wrapping a headless adapter. + /// + /// Adapter configuration. + public TestAdapter(EngineConfig config) + { + _inner = new HeadlessAdapter(config); + } + + /// + public Task InitializeAsync(EngineConfig config, CancellationToken ct = default) + { + _recordedCalls.Add(new RecordedCall("Adapter", "InitializeAsync", [config])); + return _inner.InitializeAsync(config, ct); + } + + /// + public Task ShutdownAsync(CancellationToken ct = default) + { + _recordedCalls.Add(new RecordedCall("Adapter", "ShutdownAsync", [])); + return _inner.ShutdownAsync(ct); + } + + /// + public void Dispose() => _inner.Dispose(); +} diff --git a/src/GameEngineAdapter.UnitTests/CameraDescriptorTests.cs b/src/GameEngineAdapter.UnitTests/CameraDescriptorTests.cs new file mode 100644 index 0000000..bce880d --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/CameraDescriptorTests.cs @@ -0,0 +1,67 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; + +public class CameraDescriptorTests +{ + [Fact] + public void CameraDescriptor_Construction_WithOrthographic_StoresFields() + { + // Arrange / Act + var cam = new CameraDescriptor( + Projection: ProjectionType.Orthographic, + FieldOfViewDegrees: 0f, + OrthographicSize: 10f, + AspectRatio: 16f / 9f, + NearPlane: 0.1f, + FarPlane: 100f); + + // Assert + Assert.Equal(ProjectionType.Orthographic, cam.Projection); + Assert.Equal(0f, cam.FieldOfViewDegrees); + Assert.Equal(10f, cam.OrthographicSize); + Assert.Equal(16f / 9f, cam.AspectRatio); + Assert.Equal(0.1f, cam.NearPlane); + Assert.Equal(100f, cam.FarPlane); + } + + [Fact] + public void CameraDescriptor_Construction_WithPerspective_StoresFields() + { + // Arrange / Act + var cam = new CameraDescriptor( + Projection: ProjectionType.Perspective, + FieldOfViewDegrees: 60f, + OrthographicSize: 0f, + AspectRatio: 4f / 3f, + NearPlane: 0.01f, + FarPlane: 500f); + + // Assert + Assert.Equal(ProjectionType.Perspective, cam.Projection); + Assert.Equal(60f, cam.FieldOfViewDegrees); + Assert.Equal(4f / 3f, cam.AspectRatio); + } + + [Fact] + public void CameraDescriptor_EqualityByValue() + { + // Arrange + var a = new CameraDescriptor(ProjectionType.Orthographic, 0f, 10f, 1.778f, 0.1f, 100f); + var b = new CameraDescriptor(ProjectionType.Orthographic, 0f, 10f, 1.778f, 0.1f, 100f); + + // Assert + Assert.Equal(a, b); + } + + [Fact] + public void CameraDescriptor_DifferentProjection_NotEqual() + { + // Arrange + var a = new CameraDescriptor(ProjectionType.Orthographic, 0f, 10f, 1f, 0.1f, 100f); + var b = new CameraDescriptor(ProjectionType.Perspective, 60f, 0f, 1f, 0.1f, 100f); + + // Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs b/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs new file mode 100644 index 0000000..aa8d5e5 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs @@ -0,0 +1,79 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; +using JohnLudlow.GameEngineAdapter.Headless; + +public class DeterministicEngineRunnerTests +{ + private static EngineConfig MakeConfig() => + new("Headless", null, null); + + [Fact] + public void DeterministicEngineRunner_Run_ZeroSteps_NoCommandsSubmitted() + { + // Arrange + using var adapter = new HeadlessAdapter(MakeConfig()); + var renderProvider = (HeadlessRenderProvider)adapter.RenderProvider; + var runner = new DeterministicEngineRunner(adapter, seed: 42, TimeSpan.FromMilliseconds(16)); + + // Act + runner.Run(0); + + // Assert + Assert.Empty(renderProvider.RecordedCommands); + } + + [Fact] + public void DeterministicEngineRunner_Run_CompletesWithoutThrowing() + { + // Arrange + using var adapter = new HeadlessAdapter(MakeConfig()); + var runner = new DeterministicEngineRunner(adapter, seed: 0, TimeSpan.FromMilliseconds(16)); + + // Act / Assert — must not throw + runner.Run(5); + } + + [Fact] + public void DeterministicEngineRunner_Run_MultipleSteps_ExecutesEachStep() + { + // Arrange + using var adapter = new HeadlessAdapter(MakeConfig()); + var runner = new DeterministicEngineRunner(adapter, seed: 1, TimeSpan.FromMilliseconds(16)); + + // Act — run succeeds without exception for multiple steps + runner.Run(10); + + // Assert — runner completed 10 steps (no throw is the primary assertion here, + // since the headless runner calls BeginFrame/EndFrame/Present per step) + // We verify the render provider itself is functional by checking it was used + var renderProvider = (HeadlessRenderProvider)adapter.RenderProvider; + Assert.NotNull(renderProvider); + } + + [Fact] + public void DeterministicEngineRunner_Run_DifferentSeeds_BothComplete() + { + // Arrange + using var adapter1 = new HeadlessAdapter(MakeConfig()); + using var adapter2 = new HeadlessAdapter(MakeConfig()); + var runner1 = new DeterministicEngineRunner(adapter1, seed: 100, TimeSpan.FromMilliseconds(16)); + var runner2 = new DeterministicEngineRunner(adapter2, seed: 200, TimeSpan.FromMilliseconds(16)); + + // Act / Assert — both seeds must complete without error + runner1.Run(3); + runner2.Run(3); + } + + [Fact] + public void DeterministicEngineRunner_Run_RepeatedCallsOnSameRunner_DoNotThrow() + { + // Arrange + using var adapter = new HeadlessAdapter(MakeConfig()); + var runner = new DeterministicEngineRunner(adapter, seed: 7, TimeSpan.FromMilliseconds(33)); + + // Act / Assert + runner.Run(2); + runner.Run(3); + } +} diff --git a/src/GameEngineAdapter.UnitTests/EngineCapabilitiesTests.cs b/src/GameEngineAdapter.UnitTests/EngineCapabilitiesTests.cs new file mode 100644 index 0000000..97814f9 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/EngineCapabilitiesTests.cs @@ -0,0 +1,60 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; + +public class EngineCapabilitiesTests +{ + [Fact] + public void EngineCapabilities_Construction_StoresAllFields() + { + // Arrange + var formats = new List { "png", "jpg" }; + + // Act + var caps = new EngineCapabilities( + Supports2D: true, + Supports3D: false, + SupportsShaders: true, + SupportsAudio: false, + SupportsRichUI: true, + ContractVersion: "1.0.0", + SupportedTextureFormats: formats, + MaxTextureSize: 4096, + MaxAudioChannels: 0); + + // Assert + Assert.True(caps.Supports2D); + Assert.False(caps.Supports3D); + Assert.True(caps.SupportsShaders); + Assert.False(caps.SupportsAudio); + Assert.True(caps.SupportsRichUI); + Assert.Equal("1.0.0", caps.ContractVersion); + Assert.Equal(formats, caps.SupportedTextureFormats); + Assert.Equal(4096, caps.MaxTextureSize); + Assert.Equal(0, caps.MaxAudioChannels); + } + + [Fact] + public void EngineCapabilities_EqualityByValue() + { + // Arrange + var formats = new List(); + var a = new EngineCapabilities(true, false, false, false, false, "1.0.0", formats, 1024, 8); + var b = new EngineCapabilities(true, false, false, false, false, "1.0.0", formats, 1024, 8); + + // Assert + Assert.Equal(a, b); + } + + [Fact] + public void EngineCapabilities_DifferentContractVersion_NotEqual() + { + // Arrange + var formats = new List(); + var a = new EngineCapabilities(true, false, false, false, false, "1.0.0", formats, 0, 0); + var b = new EngineCapabilities(true, false, false, false, false, "2.0.0", formats, 0, 0); + + // Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/EngineConfigTests.cs b/src/GameEngineAdapter.UnitTests/EngineConfigTests.cs new file mode 100644 index 0000000..245c5d2 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/EngineConfigTests.cs @@ -0,0 +1,72 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; + +public class EngineConfigTests +{ + [Fact] + public void EngineConfig_Construction_StoresAdapterName() + { + // Arrange / Act + var config = new EngineConfig("MonoGame", null, null); + + // Assert + Assert.Equal("MonoGame", config.AdapterName); + } + + [Fact] + public void EngineConfig_Construction_StoresResourcePath() + { + // Arrange / Act + var config = new EngineConfig("Headless", "/assets", null); + + // Assert + Assert.Equal("/assets", config.ResourcePath); + } + + [Fact] + public void EngineConfig_Construction_NullResourcePathIsAllowed() + { + // Arrange / Act + var config = new EngineConfig("Headless", null, null); + + // Assert + Assert.Null(config.ResourcePath); + } + + [Fact] + public void EngineConfig_Construction_StoresOptions() + { + // Arrange + var options = new Dictionary { ["vsync"] = (object)true }; + + // Act + var config = new EngineConfig("Headless", null, options); + + // Assert + Assert.NotNull(config.Options); + Assert.Equal(true, config.Options["vsync"]); + } + + [Fact] + public void EngineConfig_NullOptions_IsAllowed() + { + // Arrange / Act + var config = new EngineConfig("Headless", null, null); + + // Assert + Assert.Null(config.Options); + } + + [Fact] + public void EngineConfig_EqualityByValue() + { + // Arrange + var a = new EngineConfig("Headless", "/res", null); + var b = new EngineConfig("Headless", "/res", null); + + // Assert + Assert.Equal(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj b/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj index 618a3b8..2bc800d 100644 --- a/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj +++ b/src/GameEngineAdapter.UnitTests/GameEngineAdapter.UnitTests.csproj @@ -15,6 +15,11 @@ + + + + + diff --git a/src/GameEngineAdapter.UnitTests/HeadlessAdapterTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessAdapterTests.cs new file mode 100644 index 0000000..637d0f1 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessAdapterTests.cs @@ -0,0 +1,123 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; +using JohnLudlow.GameEngineAdapter.Headless; + +public class HeadlessAdapterTests +{ + private static EngineConfig MakeConfig() => + new("Headless", null, null); + + [Fact] + public void HeadlessAdapter_Constructor_StoresConfig() + { + // Arrange + var config = MakeConfig(); + + // Act + using var adapter = new HeadlessAdapter(config); + + // Assert + Assert.Equal(config, adapter.Config); + } + + [Fact] + public void HeadlessAdapter_Capabilities_Supports2D() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.True(adapter.Capabilities.Supports2D); + } + + [Fact] + public void HeadlessAdapter_Capabilities_ContractVersionIsSet() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.False(string.IsNullOrEmpty(adapter.Capabilities.ContractVersion)); + } + + [Fact] + public void HeadlessAdapter_RenderProvider_IsNotNull() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.RenderProvider); + } + + [Fact] + public void HeadlessAdapter_InputProvider_IsNotNull() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.InputProvider); + } + + [Fact] + public void HeadlessAdapter_UserInterfaceProvider_IsNotNull() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.UserInterfaceProvider); + } + + [Fact] + public void HeadlessAdapter_AssetProvider_IsNotNull() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.AssetProvider); + } + + [Fact] + public void HeadlessAdapter_AudioPlayer_IsNotNull() + { + // Arrange / Act + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.AudioPlayer); + } + + [Fact] + public async Task HeadlessAdapter_InitializeAsync_Completes() + { + // Arrange + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Act / Assert — must not throw + await adapter.InitializeAsync(MakeConfig()); + } + + [Fact] + public async Task HeadlessAdapter_ShutdownAsync_Completes() + { + // Arrange + using var adapter = new HeadlessAdapter(MakeConfig()); + + // Act / Assert — must not throw + await adapter.ShutdownAsync(); + } + + [Fact] + public void HeadlessAdapter_Dispose_DoesNotThrow() + { + // Arrange + var adapter = new HeadlessAdapter(MakeConfig()); + + // Act / Assert — must not throw + adapter.Dispose(); + } +} diff --git a/src/GameEngineAdapter.UnitTests/HeadlessAssetLoaderTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessAssetLoaderTests.cs new file mode 100644 index 0000000..74098bd --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessAssetLoaderTests.cs @@ -0,0 +1,56 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Headless; + +public class HeadlessAssetLoaderTests +{ + [Fact] + public void HeadlessAssetLoader_Load_ReturnsNonNull() + { + // Arrange + var loader = new HeadlessAssetLoader(); + + // Act + var result = loader.Load("texture_hero"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void HeadlessAssetLoader_Load_ReturnsDifferentInstancesPerCall() + { + // Arrange + var loader = new HeadlessAssetLoader(); + + // Act + var a = loader.Load("asset_a"); + var b = loader.Load("asset_b"); + + // Assert — each call produces a fresh object + Assert.NotSame(a, b); + } + + [Fact] + public async Task HeadlessAssetLoader_LoadAsync_ReturnsNonNull() + { + // Arrange + var loader = new HeadlessAssetLoader(); + + // Act + var result = await loader.LoadAsync("texture_background"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task HeadlessAssetLoader_LoadAsync_Completes() + { + // Arrange + var loader = new HeadlessAssetLoader(); + + // Act / Assert — must not throw or hang + await loader.LoadAsync("audio_sfx"); + } +} diff --git a/src/GameEngineAdapter.UnitTests/HeadlessAssetProviderTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessAssetProviderTests.cs new file mode 100644 index 0000000..a6723a0 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessAssetProviderTests.cs @@ -0,0 +1,111 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Headless; + +public class HeadlessAssetProviderTests +{ + [Fact] + public void HeadlessAssetProvider_Loader_IsNotNull() + { + // Arrange / Act + var provider = new HeadlessAssetProvider(); + + // Assert + Assert.NotNull(provider.Loader); + } + + [Fact] + public void HeadlessAssetProvider_GetAsset_UnknownId_ReturnsNull() + { + // Arrange + var provider = new HeadlessAssetProvider(); + + // Act + var result = provider.GetAsset("nonexistent_asset"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void HeadlessAssetProvider_LoadAndCache_CachesAsset() + { + // Arrange + var provider = new HeadlessAssetProvider(); + + // Act + provider.LoadAndCache("texture_sky"); + var result = provider.GetAsset("texture_sky"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void HeadlessAssetProvider_IsAssetLoaded_ReturnsFalseBeforeLoad() + { + // Arrange + var provider = new HeadlessAssetProvider(); + + // Act / Assert + Assert.False(provider.IsAssetLoaded("font_arial")); + } + + [Fact] + public void HeadlessAssetProvider_IsAssetLoaded_ReturnsTrueAfterLoad() + { + // Arrange + var provider = new HeadlessAssetProvider(); + provider.LoadAndCache("font_arial"); + + // Act / Assert + Assert.True(provider.IsAssetLoaded("font_arial")); + } + + [Fact] + public void HeadlessAssetProvider_UnloadAsset_RemovesFromCache() + { + // Arrange + var provider = new HeadlessAssetProvider(); + provider.LoadAndCache("mesh_tree"); + + // Act + provider.UnloadAsset("mesh_tree"); + + // Assert + Assert.False(provider.IsAssetLoaded("mesh_tree")); + Assert.Null(provider.GetAsset("mesh_tree")); + } + + [Fact] + public void HeadlessAssetProvider_ClearCache_RemovesAllAssets() + { + // Arrange + var provider = new HeadlessAssetProvider(); + provider.LoadAndCache("sprite_hero"); + provider.LoadAndCache("sprite_enemy"); + + // Act + provider.ClearCache(); + + // Assert + Assert.False(provider.IsAssetLoaded("sprite_hero")); + Assert.False(provider.IsAssetLoaded("sprite_enemy")); + } + + [Fact] + public void HeadlessAssetProvider_LoadAndCache_CalledTwice_DoesNotReplaceExisting() + { + // Arrange + var provider = new HeadlessAssetProvider(); + provider.LoadAndCache("texture_rock"); + var first = provider.GetAsset("texture_rock"); + + // Act — load again, should not replace + provider.LoadAndCache("texture_rock"); + var second = provider.GetAsset("texture_rock"); + + // Assert — same instance returned both times + Assert.Same(first, second); + } +} diff --git a/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs new file mode 100644 index 0000000..98002f6 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs @@ -0,0 +1,114 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Headless; + +public class HeadlessAudioPlayerTests +{ + [Fact] + public void HeadlessAudioPlayer_RecordedCalls_InitiallyEmpty() + { + // Arrange / Act + var player = new HeadlessAudioPlayer(); + + // Assert + Assert.Empty(player.RecordedCalls); + } + + [Fact] + public void HeadlessAudioPlayer_Play_RecordsCall() + { + // Arrange + var player = new HeadlessAudioPlayer(); + + // Act + player.Play("music_main", loop: true); + + // Assert + Assert.Single(player.RecordedCalls); + var call = player.RecordedCalls[0]; + Assert.Equal("Play", call.Method); + Assert.Equal("music_main", call.AudioAssetId); + Assert.Equal(true, call.Arg); + } + + [Fact] + public void HeadlessAudioPlayer_Play_WithDefaultLoop_RecordsFalse() + { + // Arrange + var player = new HeadlessAudioPlayer(); + + // Act + player.Play("sfx_jump"); + + // Assert + var call = player.RecordedCalls[0]; + Assert.Equal(false, call.Arg); + } + + [Fact] + public void HeadlessAudioPlayer_Stop_RecordsCall() + { + // Arrange + var player = new HeadlessAudioPlayer(); + + // Act + player.Stop("music_main"); + + // Assert + Assert.Single(player.RecordedCalls); + var call = player.RecordedCalls[0]; + Assert.Equal("Stop", call.Method); + Assert.Equal("music_main", call.AudioAssetId); + Assert.Null(call.Arg); + } + + [Fact] + public void HeadlessAudioPlayer_SetVolume_RecordsCall() + { + // Arrange + var player = new HeadlessAudioPlayer(); + + // Act + player.SetVolume("music_main", 0.5f); + + // Assert + Assert.Single(player.RecordedCalls); + var call = player.RecordedCalls[0]; + Assert.Equal("SetVolume", call.Method); + Assert.Equal("music_main", call.AudioAssetId); + Assert.Equal(0.5f, call.Arg); + } + + [Fact] + public void HeadlessAudioPlayer_MultipleOperations_RecordsAllInOrder() + { + // Arrange + var player = new HeadlessAudioPlayer(); + + // Act + player.Play("sfx_jump"); + player.SetVolume("sfx_jump", 0.8f); + player.Stop("sfx_jump"); + + // Assert + Assert.Equal(3, player.RecordedCalls.Count); + Assert.Equal("Play", player.RecordedCalls[0].Method); + Assert.Equal("SetVolume", player.RecordedCalls[1].Method); + Assert.Equal("Stop", player.RecordedCalls[2].Method); + } + + [Fact] + public void HeadlessAudioPlayer_Clear_RemovesAllCalls() + { + // Arrange + var player = new HeadlessAudioPlayer(); + player.Play("sfx_a"); + player.Stop("sfx_b"); + + // Act + player.Clear(); + + // Assert + Assert.Empty(player.RecordedCalls); + } +} diff --git a/src/GameEngineAdapter.UnitTests/HeadlessEngineCapabilitiesTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessEngineCapabilitiesTests.cs new file mode 100644 index 0000000..4427847 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessEngineCapabilitiesTests.cs @@ -0,0 +1,57 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; + +public class HeadlessEngineCapabilitiesTests +{ + private static EngineCapabilities MakeBase() => + new(true, false, false, false, false, "1.0.0", [], 0, 0); + + [Fact] + public void HeadlessEngineCapabilities_Construction_WrapsBase() + { + // Arrange + var baseCaps = MakeBase(); + + // Act + var caps = new HeadlessEngineCapabilities( + Base: baseCaps, + SupportsOffscreenRendering: true, + DeterministicTick: true); + + // Assert + Assert.Equal(baseCaps, caps.Base); + } + + [Fact] + public void HeadlessEngineCapabilities_SupportsOffscreenRendering_IsStored() + { + // Arrange / Act + var caps = new HeadlessEngineCapabilities(MakeBase(), SupportsOffscreenRendering: true, DeterministicTick: false); + + // Assert + Assert.True(caps.SupportsOffscreenRendering); + } + + [Fact] + public void HeadlessEngineCapabilities_DeterministicTick_IsStored() + { + // Arrange / Act + var caps = new HeadlessEngineCapabilities(MakeBase(), SupportsOffscreenRendering: false, DeterministicTick: true); + + // Assert + Assert.True(caps.DeterministicTick); + } + + [Fact] + public void HeadlessEngineCapabilities_EqualityByValue() + { + // Arrange + var baseCaps = MakeBase(); + var a = new HeadlessEngineCapabilities(baseCaps, true, true); + var b = new HeadlessEngineCapabilities(baseCaps, true, true); + + // Assert + Assert.Equal(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/HeadlessInputProviderTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessInputProviderTests.cs new file mode 100644 index 0000000..a48e4a4 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessInputProviderTests.cs @@ -0,0 +1,143 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Headless; + +public class HeadlessInputProviderTests +{ + [Fact] + public void HeadlessInputProvider_ScriptKeyDown_KeyIsDown() + { + // Arrange + var provider = new HeadlessInputProvider(); + + // Act + provider.ScriptKeyDown("Space"); + + // Assert + Assert.True(provider.IsKeyDown("Space")); + } + + [Fact] + public void HeadlessInputProvider_ScriptKeyUp_AfterDown_KeyIsUp() + { + // Arrange + var provider = new HeadlessInputProvider(); + provider.ScriptKeyDown("Enter"); + + // Act + provider.ScriptKeyUp("Enter"); + + // Assert + Assert.False(provider.IsKeyDown("Enter")); + } + + [Fact] + public void HeadlessInputProvider_IsKeyDown_UnscriptedKey_ReturnsFalse() + { + // Arrange + var provider = new HeadlessInputProvider(); + + // Act / Assert + Assert.False(provider.IsKeyDown("A")); + } + + [Fact] + public void HeadlessInputProvider_ScriptMouseButtonDown_ButtonIsDown() + { + // Arrange + var provider = new HeadlessInputProvider(); + + // Act + provider.ScriptMouseButtonDown(0); + + // Assert + Assert.True(provider.IsMouseButtonDown(0)); + } + + [Fact] + public void HeadlessInputProvider_ScriptMouseButtonUp_AfterDown_ButtonIsUp() + { + // Arrange + var provider = new HeadlessInputProvider(); + provider.ScriptMouseButtonDown(1); + + // Act + provider.ScriptMouseButtonUp(1); + + // Assert + Assert.False(provider.IsMouseButtonDown(1)); + } + + [Fact] + public void HeadlessInputProvider_IsMouseButtonDown_UnscriptedButton_ReturnsFalse() + { + // Arrange + var provider = new HeadlessInputProvider(); + + // Act / Assert + Assert.False(provider.IsMouseButtonDown(2)); + } + + [Fact] + public void HeadlessInputProvider_ScriptMousePosition_SetsPosition() + { + // Arrange + var provider = new HeadlessInputProvider(); + + // Act + provider.ScriptMousePosition(320f, 240f); + + // Assert + var (x, y) = provider.GetMousePosition(); + Assert.Equal(320f, x); + Assert.Equal(240f, y); + } + + [Fact] + public void HeadlessInputProvider_Reset_ClearsKeyState() + { + // Arrange + var provider = new HeadlessInputProvider(); + provider.ScriptKeyDown("W"); + provider.ScriptKeyDown("A"); + + // Act + provider.Reset(); + + // Assert + Assert.False(provider.IsKeyDown("W")); + Assert.False(provider.IsKeyDown("A")); + } + + [Fact] + public void HeadlessInputProvider_Reset_ClearsMouseButtonState() + { + // Arrange + var provider = new HeadlessInputProvider(); + provider.ScriptMouseButtonDown(0); + provider.ScriptMouseButtonDown(1); + + // Act + provider.Reset(); + + // Assert + Assert.False(provider.IsMouseButtonDown(0)); + Assert.False(provider.IsMouseButtonDown(1)); + } + + [Fact] + public void HeadlessInputProvider_Reset_ResetsMousePositionToOrigin() + { + // Arrange + var provider = new HeadlessInputProvider(); + provider.ScriptMousePosition(100f, 200f); + + // Act + provider.Reset(); + + // Assert + var (x, y) = provider.GetMousePosition(); + Assert.Equal(0f, x); + Assert.Equal(0f, y); + } +} diff --git a/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs new file mode 100644 index 0000000..5746570 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs @@ -0,0 +1,136 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; +using JohnLudlow.GameEngineAdapter.Headless; + +public class HeadlessRenderProviderTests +{ + private static CameraDescriptor MakeCamera() => + new(ProjectionType.Orthographic, 0f, 10f, 16f / 9f, 0.1f, 100f); + + private static TransformDto MakeTransform() => + new(0f, 0f, 0f, 0f, 0f, 0f, 1f, 1f, 1f); + + private static MaterialDto MakeMaterial() => + new("default", new Dictionary(), []); + + [Fact] + public void HeadlessRenderProvider_RecordedCommands_InitiallyEmpty() + { + // Arrange / Act + var provider = new HeadlessRenderProvider(); + + // Assert + Assert.Empty(provider.RecordedCommands); + } + + [Fact] + public void HeadlessRenderProvider_SubmitSprite_RecordsCommand() + { + // Arrange + var provider = new HeadlessRenderProvider(); + var dto = new SpriteDrawDto("sprite_test", MakeTransform(), MakeMaterial(), 0); + + // Act + provider.SubmitSprite(dto); + + // Assert + Assert.Single(provider.RecordedCommands); + Assert.Equal(dto, provider.RecordedCommands[0]); + } + + [Fact] + public void HeadlessRenderProvider_SubmitText_RecordsCommand() + { + // Arrange + var provider = new HeadlessRenderProvider(); + var dto = new TextDrawDto("Hello", MakeTransform(), "font", 12f, MakeMaterial(), 0); + + // Act + provider.SubmitText(dto); + + // Assert + Assert.Single(provider.RecordedCommands); + Assert.Equal(dto, provider.RecordedCommands[0]); + } + + [Fact] + public void HeadlessRenderProvider_SubmitMesh_RecordsCommand() + { + // Arrange + var provider = new HeadlessRenderProvider(); + var dto = new MeshDrawDto("mesh_test", MakeTransform(), MakeMaterial(), 0); + + // Act + provider.SubmitMesh(dto); + + // Assert + Assert.Single(provider.RecordedCommands); + Assert.Equal(dto, provider.RecordedCommands[0]); + } + + [Fact] + public void HeadlessRenderProvider_MultipleSubmits_RecordsAllCommands() + { + // Arrange + var provider = new HeadlessRenderProvider(); + + // Act + provider.SubmitSprite(new SpriteDrawDto("s1", MakeTransform(), MakeMaterial(), 0)); + provider.SubmitSprite(new SpriteDrawDto("s2", MakeTransform(), MakeMaterial(), 1)); + provider.SubmitText(new TextDrawDto("Hi", MakeTransform(), "f", 10f, MakeMaterial(), 2)); + + // Assert + Assert.Equal(3, provider.RecordedCommands.Count); + } + + [Fact] + public void HeadlessRenderProvider_Clear_RemovesAllCommands() + { + // Arrange + var provider = new HeadlessRenderProvider(); + provider.SubmitSprite(new SpriteDrawDto("s", MakeTransform(), MakeMaterial(), 0)); + provider.SubmitMesh(new MeshDrawDto("m", MakeTransform(), MakeMaterial(), 0)); + + // Act + provider.Clear(); + + // Assert + Assert.Empty(provider.RecordedCommands); + } + + [Fact] + public void HeadlessRenderProvider_BeginFrame_ReturnsFrameScope() + { + // Arrange + var provider = new HeadlessRenderProvider(); + var camera = MakeCamera(); + + // Act + var scope = provider.BeginFrame(in camera); + + // Assert — FrameScope is a struct, just verify it doesn't throw and is disposable + scope.Dispose(); + } + + [Fact] + public void HeadlessRenderProvider_EndFrame_DoesNotThrow() + { + // Arrange + var provider = new HeadlessRenderProvider(); + + // Act / Assert + provider.EndFrame(); + } + + [Fact] + public void HeadlessRenderProvider_Present_DoesNotThrow() + { + // Arrange + var provider = new HeadlessRenderProvider(); + + // Act / Assert + provider.Present(); + } +} diff --git a/src/GameEngineAdapter.UnitTests/MaterialDtoTests.cs b/src/GameEngineAdapter.UnitTests/MaterialDtoTests.cs new file mode 100644 index 0000000..a1d28dd --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/MaterialDtoTests.cs @@ -0,0 +1,63 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; + +public class MaterialDtoTests +{ + [Fact] + public void MaterialDto_Construction_StoresAllFields() + { + // Arrange + var uniforms = new Dictionary { ["color"] = (object)"red" }; + var textureSlots = new List { 0, 1 }; + + // Act + var material = new MaterialDto( + ShaderId: "shader_lit", + Uniforms: uniforms, + TextureSlots: textureSlots); + + // Assert + Assert.Equal("shader_lit", material.ShaderId); + Assert.Equal(uniforms, material.Uniforms); + Assert.Equal(textureSlots, material.TextureSlots); + } + + [Fact] + public void MaterialDto_Uniforms_CanBeAccessedByKey() + { + // Arrange + var uniforms = new Dictionary { ["alpha"] = (object)0.5f }; + var material = new MaterialDto("s", uniforms, []); + + // Act / Assert + Assert.Equal(0.5f, material.Uniforms["alpha"]); + } + + [Fact] + public void MaterialDto_TextureSlots_ContainsExpectedIndices() + { + // Arrange + var slots = new List { 2, 5 }; + var material = new MaterialDto("s", new Dictionary(), slots); + + // Assert + Assert.Equal(2, material.TextureSlots.Count); + Assert.Equal(2, material.TextureSlots[0]); + Assert.Equal(5, material.TextureSlots[1]); + } + + [Fact] + public void MaterialDto_EqualityByValue() + { + // Arrange + var uniforms = new Dictionary(); + var slots = new List(); + var a = new MaterialDto("s", uniforms, slots); + var b = new MaterialDto("s", uniforms, slots); + + // Assert + Assert.Equal(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/MeshDrawDtoTests.cs b/src/GameEngineAdapter.UnitTests/MeshDrawDtoTests.cs new file mode 100644 index 0000000..483f5d8 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/MeshDrawDtoTests.cs @@ -0,0 +1,60 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; + +public class MeshDrawDtoTests +{ + private static MaterialDto MakeMaterial() => + new("default", new Dictionary(), []); + + private static TransformDto MakeTransform() => + new(0f, 0f, 0f, 0f, 0f, 0f, 1f, 1f, 1f); + + [Fact] + public void MeshDrawDto_Construction_StoresAllFields() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + + // Act + var dto = new MeshDrawDto( + MeshId: "mesh_cube", + Transform: transform, + Material: material, + Layer: 2); + + // Assert + Assert.Equal("mesh_cube", dto.MeshId); + Assert.Equal(transform, dto.Transform); + Assert.Equal(material, dto.Material); + Assert.Equal(2, dto.Layer); + } + + [Fact] + public void MeshDrawDto_EqualityByValue() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + var a = new MeshDrawDto("m", transform, material, 0); + var b = new MeshDrawDto("m", transform, material, 0); + + // Assert + Assert.Equal(a, b); + } + + [Fact] + public void MeshDrawDto_DifferentMeshId_NotEqual() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + var a = new MeshDrawDto("mesh_a", transform, material, 0); + var b = new MeshDrawDto("mesh_b", transform, material, 0); + + // Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/RecordedCallTests.cs b/src/GameEngineAdapter.UnitTests/RecordedCallTests.cs new file mode 100644 index 0000000..8c9a596 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/RecordedCallTests.cs @@ -0,0 +1,74 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Headless; + +public class RecordedCallTests +{ + [Fact] + public void RecordedCall_Construction_StoresProviderName() + { + // Arrange / Act + var call = new RecordedCall("Render", "SubmitSprite", []); + + // Assert + Assert.Equal("Render", call.ProviderName); + } + + [Fact] + public void RecordedCall_Construction_StoresMethodName() + { + // Arrange / Act + var call = new RecordedCall("Input", "IsKeyDown", []); + + // Assert + Assert.Equal("IsKeyDown", call.MethodName); + } + + [Fact] + public void RecordedCall_Construction_StoresArguments() + { + // Arrange + var args = new List { "arg1", 42 }; + + // Act + var call = new RecordedCall("Adapter", "InitializeAsync", args); + + // Assert + Assert.Equal(2, call.Arguments.Count); + Assert.Equal("arg1", call.Arguments[0]); + Assert.Equal(42, call.Arguments[1]); + } + + [Fact] + public void RecordedCall_Construction_EmptyArguments_IsAllowed() + { + // Arrange / Act + var call = new RecordedCall("Adapter", "ShutdownAsync", []); + + // Assert + Assert.Empty(call.Arguments); + } + + [Fact] + public void RecordedCall_EqualityByValue_SameFields_AreEqual() + { + // Arrange + var args = new List(); + var a = new RecordedCall("Adapter", "ShutdownAsync", args); + var b = new RecordedCall("Adapter", "ShutdownAsync", args); + + // Assert + Assert.Equal(a, b); + } + + [Fact] + public void RecordedCall_DifferentMethodName_NotEqual() + { + // Arrange + var a = new RecordedCall("Adapter", "InitializeAsync", []); + var b = new RecordedCall("Adapter", "ShutdownAsync", []); + + // Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/SpriteDrawDtoTests.cs b/src/GameEngineAdapter.UnitTests/SpriteDrawDtoTests.cs new file mode 100644 index 0000000..1672bf9 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/SpriteDrawDtoTests.cs @@ -0,0 +1,60 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; + +public class SpriteDrawDtoTests +{ + private static MaterialDto MakeMaterial() => + new("default", new Dictionary(), []); + + private static TransformDto MakeTransform() => + new(0f, 0f, 0f, 0f, 0f, 0f, 1f, 1f, 1f); + + [Fact] + public void SpriteDrawDto_Construction_StoresAllFields() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + + // Act + var dto = new SpriteDrawDto( + SpriteId: "sprite_hero", + Transform: transform, + Material: material, + Layer: 5); + + // Assert + Assert.Equal("sprite_hero", dto.SpriteId); + Assert.Equal(transform, dto.Transform); + Assert.Equal(material, dto.Material); + Assert.Equal(5, dto.Layer); + } + + [Fact] + public void SpriteDrawDto_EqualityByValue() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + var a = new SpriteDrawDto("s", transform, material, 1); + var b = new SpriteDrawDto("s", transform, material, 1); + + // Assert + Assert.Equal(a, b); + } + + [Fact] + public void SpriteDrawDto_DifferentLayer_NotEqual() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + var a = new SpriteDrawDto("s", transform, material, 1); + var b = new SpriteDrawDto("s", transform, material, 2); + + // Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/TestAdapterTests.cs b/src/GameEngineAdapter.UnitTests/TestAdapterTests.cs new file mode 100644 index 0000000..32c71b2 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/TestAdapterTests.cs @@ -0,0 +1,155 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; +using JohnLudlow.GameEngineAdapter.Headless; + +public class TestAdapterTests +{ + private static EngineConfig MakeConfig() => + new("Test", null, null); + + [Fact] + public void TestAdapter_Constructor_WrapsHeadlessAdapter() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert — capabilities should match headless defaults + Assert.True(adapter.Capabilities.Supports2D); + } + + [Fact] + public void TestAdapter_RenderProvider_DelegatesToInner() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.RenderProvider); + } + + [Fact] + public void TestAdapter_InputProvider_DelegatesToInner() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.InputProvider); + } + + [Fact] + public void TestAdapter_UserInterfaceProvider_DelegatesToInner() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.UserInterfaceProvider); + } + + [Fact] + public void TestAdapter_AssetProvider_DelegatesToInner() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.AssetProvider); + } + + [Fact] + public void TestAdapter_AudioPlayer_DelegatesToInner() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert + Assert.NotNull(adapter.AudioPlayer); + } + + [Fact] + public void TestAdapter_RecordedCalls_InitiallyEmpty() + { + // Arrange / Act + using var adapter = new TestAdapter(MakeConfig()); + + // Assert + Assert.Empty(adapter.RecordedCalls); + } + + [Fact] + public async Task TestAdapter_InitializeAsync_RecordsCall() + { + // Arrange + using var adapter = new TestAdapter(MakeConfig()); + var config = MakeConfig(); + + // Act + await adapter.InitializeAsync(config); + + // Assert + Assert.Single(adapter.RecordedCalls); + var call = adapter.RecordedCalls[0]; + Assert.Equal("Adapter", call.ProviderName); + Assert.Equal("InitializeAsync", call.MethodName); + } + + [Fact] + public async Task TestAdapter_ShutdownAsync_RecordsCall() + { + // Arrange + using var adapter = new TestAdapter(MakeConfig()); + + // Act + await adapter.ShutdownAsync(); + + // Assert + Assert.Single(adapter.RecordedCalls); + var call = adapter.RecordedCalls[0]; + Assert.Equal("Adapter", call.ProviderName); + Assert.Equal("ShutdownAsync", call.MethodName); + } + + [Fact] + public async Task TestAdapter_RecordedCalls_ReflectsCallSequence() + { + // Arrange + using var adapter = new TestAdapter(MakeConfig()); + + // Act + await adapter.InitializeAsync(MakeConfig()); + await adapter.ShutdownAsync(); + + // Assert + Assert.Equal(2, adapter.RecordedCalls.Count); + Assert.Equal("InitializeAsync", adapter.RecordedCalls[0].MethodName); + Assert.Equal("ShutdownAsync", adapter.RecordedCalls[1].MethodName); + } + + [Fact] + public async Task TestAdapter_InitializeAsync_RecordsConfigAsArgument() + { + // Arrange + using var adapter = new TestAdapter(MakeConfig()); + var config = new EngineConfig("SpecificAdapter", "/res", null); + + // Act + await adapter.InitializeAsync(config); + + // Assert + var call = adapter.RecordedCalls[0]; + Assert.Single(call.Arguments); + Assert.Equal(config, call.Arguments[0]); + } + + [Fact] + public void TestAdapter_Dispose_DoesNotThrow() + { + // Arrange + var adapter = new TestAdapter(MakeConfig()); + + // Act / Assert + adapter.Dispose(); + } +} diff --git a/src/GameEngineAdapter.UnitTests/TextDrawDtoTests.cs b/src/GameEngineAdapter.UnitTests/TextDrawDtoTests.cs new file mode 100644 index 0000000..511ae76 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/TextDrawDtoTests.cs @@ -0,0 +1,64 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; + +public class TextDrawDtoTests +{ + private static MaterialDto MakeMaterial() => + new("default", new Dictionary(), []); + + private static TransformDto MakeTransform() => + new(0f, 0f, 0f, 0f, 0f, 0f, 1f, 1f, 1f); + + [Fact] + public void TextDrawDto_Construction_StoresAllFields() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + + // Act + var dto = new TextDrawDto( + Text: "Hello World", + Transform: transform, + FontId: "font_sans", + FontSize: 14f, + Material: material, + Layer: 3); + + // Assert + Assert.Equal("Hello World", dto.Text); + Assert.Equal(transform, dto.Transform); + Assert.Equal("font_sans", dto.FontId); + Assert.Equal(14f, dto.FontSize); + Assert.Equal(material, dto.Material); + Assert.Equal(3, dto.Layer); + } + + [Fact] + public void TextDrawDto_EqualityByValue() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + var a = new TextDrawDto("Hi", transform, "f", 12f, material, 0); + var b = new TextDrawDto("Hi", transform, "f", 12f, material, 0); + + // Assert + Assert.Equal(a, b); + } + + [Fact] + public void TextDrawDto_DifferentText_NotEqual() + { + // Arrange + var transform = MakeTransform(); + var material = MakeMaterial(); + var a = new TextDrawDto("Hello", transform, "f", 12f, material, 0); + var b = new TextDrawDto("Goodbye", transform, "f", 12f, material, 0); + + // Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/TransformDtoTests.cs b/src/GameEngineAdapter.UnitTests/TransformDtoTests.cs new file mode 100644 index 0000000..fb0d408 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/TransformDtoTests.cs @@ -0,0 +1,64 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests; + +using JohnLudlow.GameEngineAdapter.Core; + +public class TransformDtoTests +{ + [Fact] + public void TransformDto_Construction_StoresAllFields() + { + // Arrange / Act + var transform = new TransformDto( + X: 1f, Y: 2f, Z: 3f, + RotationX: 10f, RotationY: 20f, RotationZ: 30f, + ScaleX: 0.5f, ScaleY: 1.5f, ScaleZ: 2.0f); + + // Assert + Assert.Equal(1f, transform.X); + Assert.Equal(2f, transform.Y); + Assert.Equal(3f, transform.Z); + Assert.Equal(10f, transform.RotationX); + Assert.Equal(20f, transform.RotationY); + Assert.Equal(30f, transform.RotationZ); + Assert.Equal(0.5f, transform.ScaleX); + Assert.Equal(1.5f, transform.ScaleY); + Assert.Equal(2.0f, transform.ScaleZ); + } + + [Fact] + public void TransformDto_DefaultConstruction_AllFieldsAreZero() + { + // Arrange / Act + var transform = new TransformDto( + X: 0f, Y: 0f, Z: 0f, + RotationX: 0f, RotationY: 0f, RotationZ: 0f, + ScaleX: 0f, ScaleY: 0f, ScaleZ: 0f); + + // Assert + Assert.Equal(0f, transform.X); + Assert.Equal(0f, transform.Y); + Assert.Equal(0f, transform.Z); + } + + [Fact] + public void TransformDto_EqualityByValue() + { + // Arrange + var a = new TransformDto(1f, 2f, 3f, 0f, 0f, 0f, 1f, 1f, 1f); + var b = new TransformDto(1f, 2f, 3f, 0f, 0f, 0f, 1f, 1f, 1f); + + // Act / Assert + Assert.Equal(a, b); + } + + [Fact] + public void TransformDto_InequalityWhenFieldsDiffer() + { + // Arrange + var a = new TransformDto(1f, 2f, 3f, 0f, 0f, 0f, 1f, 1f, 1f); + var b = new TransformDto(4f, 5f, 6f, 0f, 0f, 0f, 1f, 1f, 1f); + + // Act / Assert + Assert.NotEqual(a, b); + } +} diff --git a/src/GameEngineAdapter.UnitTests/UnitTest1.cs b/src/GameEngineAdapter.UnitTests/UnitTest1.cs deleted file mode 100644 index 5b45640..0000000 --- a/src/GameEngineAdapter.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GameEngineAdapter.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file From 5c5a714f8129c5b7cd2b3a5bf77561c64fc0b563 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 18:15:55 +0100 Subject: [PATCH 04/19] Refactor IAudioPlayer interface methods for clarity and consistency --- docs/plans/phase-1-interface-development.md | 13 ++-- .../phase-2-headless-adapter-development.md | 75 ++++++++++--------- src/GameEngineAdapter.Core/IAudioPlayer.cs | 4 +- .../HeadlessAudioPlayer.cs | 6 +- .../HeadlessAudioPlayerTests.cs | 14 ++-- 5 files changed, 61 insertions(+), 51 deletions(-) diff --git a/docs/plans/phase-1-interface-development.md b/docs/plans/phase-1-interface-development.md index 65a76ba..7f6423f 100644 --- a/docs/plans/phase-1-interface-development.md +++ b/docs/plans/phase-1-interface-development.md @@ -34,7 +34,7 @@ Phase 1 defines the stable adapter contracts and DTO shapes used by all adapters ## Plan status -Complete +Complete (interfaces and DTOs implemented) ## Definition of terms @@ -79,9 +79,11 @@ Define stable adapter contracts and DTO shapes. Produce small, well-documented C - Define `IAudioPlayer` (play/stop/volume, audio asset references), `IAssetProvider` for asset caching and lifecycle management (unload, query, cache eviction), and `IAssetLoader` for loading assets from storage into the asset provider. - Provide capability descriptor model (`EngineCapabilities`) returned at adapter init and allow specific engine capability variants (e.g. `HeadlessEngineCapabilities`) to derive from the base capabilities. -#### Missing type definitions +#### Type definitions -The following types are referenced by existing interfaces but do not yet have source files. They must be created in `src/GameEngineAdapter/` under the `JohnLudlow.GameEngineAdapter.Core` namespace, following the `readonly record struct` and single-file-per-type conventions. +The types listed below were originally referenced by the contracts before the Core project was fully fleshed out. They are now implemented under `src/GameEngineAdapter.Core/` (one file per type) in the `JohnLudlow.GameEngineAdapter.Core` namespace. + +The snippets are retained as illustrative examples; prefer the source files as the canonical definitions. ##### EngineConfig @@ -284,7 +286,8 @@ public interface IAssetLoader - (***Complete***) Adapter lifecycle & capability negotiation - GIVEN adapters are present at startup - WHEN the game queries capabilities - - THEN `IEngineAdapter` returns `EngineCapabilities` and exposes `Initialize`/`Shutdown` semantics and diagnostics for mismatches. + - THEN `IEngineAdapter` returns `EngineCapabilities` and exposes `Initialize`/`Shutdown` semantics. + - Note: contract mismatch diagnostics are not yet implemented in this repository. #### Examples @@ -460,7 +463,7 @@ public readonly record struct MaterialDto( - Unit tests: provide small translator tests that map DTOs to engine calls using fakes. - Integration tests: use `HeadlessAdapter` and `TestAdapter` with recorded traces to verify behaviour. -- Compatibility: version `IEngineAdapter` via a `ContractVersion` in `EngineCapabilities`; adapters must detect mismatches and fail with actionable diagnostics. +- Compatibility: version `IEngineAdapter` via `EngineCapabilities.ContractVersion`. This repository does not yet provide a central contract validator; hosts/adapters should compare versions and fail fast with actionable diagnostics. ### Performance targets diff --git a/docs/plans/phase-2-headless-adapter-development.md b/docs/plans/phase-2-headless-adapter-development.md index db801ec..b05c24b 100644 --- a/docs/plans/phase-2-headless-adapter-development.md +++ b/docs/plans/phase-2-headless-adapter-development.md @@ -42,7 +42,7 @@ Phase 2 implements a minimal, dependency-free headless adapter for the GameEngin ## Plan status -Complete +Partially complete ## Definition of terms @@ -54,15 +54,15 @@ Complete | HeadlessAdapter | An adapter implementation that simulates engine operations without platform or GPU dependencies. | | | No-op | An operation or method that performs no action (no operation), used to satisfy interface contracts without side effects. | | | Seeded RNG | A random number generator initialized with a specific seed to produce reproducible sequences across runs. | | -| TestAdapter | An adapter implementation that records all calls for verification and assertion in automated tests. | | +| TestAdapter | An adapter implementation that records calls for verification and assertion in automated tests (currently adapter lifecycle calls). | | ## Architectural considerations and constraints - **Separate assembly**: The headless adapter lives in a dedicated assembly (`GameEngineAdapter.Headless`) under the `JohnLudlow.GameEngineAdapter.Headless` namespace. This isolates the headless implementation from the core contracts defined in `JohnLudlow.GameEngineAdapter.Core`, ensuring engine-specific adapters do not depend on each other. The headless assembly references the core assembly via a project reference. - **Dependency on Phase 1 interfaces**: The HeadlessAdapter and TestAdapter must implement all interfaces defined in Phase 1 (`IEngineAdapter`, `IRenderProvider`, `IInputProvider`, `IUserInterfaceProvider`, `IAssetProvider`, `IAudioPlayer`, `IAssetLoader`). Implementation cannot begin until Phase 1 interfaces are finalized. - **No native platform dependencies**: The implementation must not use any native APIs, GPU, audio hardware, or OS-specific features. All adapters must run on any platform supported by .NET 10. -- **Deterministic execution**: All operations (render, input, audio) must be reproducible given the same scenario, seed, and scripted inputs. -- **Call recording architecture**: Both HeadlessAdapter and TestAdapter record all relevant provider calls for later inspection and assertion. Recording uses in-memory lists bounded by scenario size. +- **Deterministic execution**: The goal is reproducible behaviour given the same scenario, seed, and scripted inputs. The current implementation provides deterministic building blocks (scriptable input state and a deterministic runner skeleton). +- **Call recording architecture**: Headless providers record their own interactions (e.g., render commands and audio calls). `TestAdapter` currently records adapter lifecycle calls only; provider call recording is not yet implemented. - **Separation of concerns**: Each provider (render, input, UI, asset, audio) is independently testable and replaceable. - **CI compatibility**: The adapters must run in CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware. @@ -70,10 +70,11 @@ Complete ### Plan requirements -- (***Complete***) Headless adapter passes integration scenarios for AI, combat and map generation. +- (***Not complete***) Headless adapter passes integration scenarios for AI, combat and map generation. - GIVEN deterministic seeds and scenario scripts - WHEN CI runs the integration suite - THEN results are stable and asserted by tests. + - Note: the repository currently has unit tests for the headless components, but no AI/combat/map-generation integration scenario suite. ### Phase 1 — HeadlessAdapter core @@ -85,12 +86,12 @@ Implement a `HeadlessAdapter` and its providers that simulate all engine operati #### Technical details -- **HeadlessAdapter**: Implements `IEngineAdapter`. Composes headless providers for rendering, input, UI, assets, and audio. Returns `HeadlessEngineCapabilities` from the `Capabilities` property. +- **HeadlessAdapter**: Implements `IEngineAdapter`. Composes headless providers for rendering, input, UI, assets, and audio. Currently returns `EngineCapabilities` (including `ContractVersion`) from the `Capabilities` property. - **HeadlessRenderProvider**: Implements `IRenderProvider`. Records all render commands (`SubmitSprite`, `SubmitText`, `SubmitMesh`) to an in-memory list for later inspection. `BeginFrame` returns a `FrameScope`; `EndFrame` and `Present` are no-ops. -- **HeadlessInputProvider**: Implements `IInputProvider`. Simulates input via a queue of scripted events, enabling deterministic input playback. Events are consumed in order per tick. -- **HeadlessUserInterfaceProvider**: Implements `IUserInterfaceProvider`. Executes UI layout and hit-testing logic without rendering any visuals. +- **HeadlessInputProvider**: Implements `IInputProvider`. Provides deterministic, scriptable input state via methods such as `ScriptKeyDown`, `ScriptKeyUp`, and `ScriptMousePosition`. +- **HeadlessUserInterfaceProvider**: Implements `IUserInterfaceProvider`. Currently empty (pending final UI provider contract). - **HeadlessAssetProvider**: Implements `IAssetProvider`. Manages asset cache and lifecycle state in memory without actual file or resource access. Composes a `HeadlessAssetLoader` (implementing `IAssetLoader`) that returns stub assets for any requested identifier. -- **HeadlessAudioPlayer**: Implements `IAudioPlayer`. All methods are no-ops or record calls for later verification. +- **HeadlessAudioPlayer**: Implements `IAudioPlayer`. Records calls (`Play`, `Stop`, `SetVolume`) for test assertion. #### Phase requirements @@ -404,27 +405,32 @@ public sealed class HeadlessAudioPlayer : IAudioPlayer ### Phase 2 — TestAdapter for CI -***Complete*** +***Partially complete*** #### Objective -Provide a `TestAdapter` that wraps the HeadlessAdapter, intercepting and recording all method calls and parameters for assertion and verification in CI environments. +Provide a `TestAdapter` that wraps the HeadlessAdapter and records calls for assertion in CI environments. #### Technical details -- **TestAdapter**: Implements `IEngineAdapter`. Composes a `HeadlessAdapter` internally and wraps each provider with a recording decorator. -- **Recording decorators**: Each provider is wrapped with a decorator that logs all method calls and their arguments to a shared in-memory list before delegating to the inner provider. -- **Assertion API**: Exposes `RecordedCalls` for test code to inspect and assert the sequence and arguments of adapter interactions. +- **TestAdapter**: Implements `IEngineAdapter`. Composes a `HeadlessAdapter` internally. +- **Call recording**: Currently records adapter lifecycle calls (`InitializeAsync`, `ShutdownAsync`) to `RecordedCalls`. +- **Provider recording**: Provider calls are currently delegated directly to the inner providers (no recording decorators yet). #### Phase requirements -- (***Complete***) TestAdapter records all adapter calls +- (***Complete***) TestAdapter records adapter lifecycle calls + - GIVEN a TestAdapter wrapping a HeadlessAdapter + - WHEN `InitializeAsync` and `ShutdownAsync` are called + - THEN the calls and arguments are recorded for assertion. + +- (***Not complete***) TestAdapter records provider method calls - GIVEN a TestAdapter wrapping a HeadlessAdapter - WHEN any provider method is called - THEN the call and its arguments are recorded for assertion. -- (***Complete***) TestAdapter supports call assertion - - GIVEN a test scenario and expected sequence of adapter calls +- (***Complete***) TestAdapter supports call assertion (for recorded calls) + - GIVEN expected sequences of recorded calls - WHEN the test completes - THEN recorded calls can be queried and asserted for correctness. @@ -509,29 +515,29 @@ public sealed class TestAdapter : IEngineAdapter ### Phase 3 — Deterministic execution -***Complete*** +***Partially complete*** #### Objective -Ensure all simulation and engine operations are deterministic, supporting fixed timestep updates and seeded random number generation for reproducible test runs. Success criteria: running the same scenario with the same seed produces identical render command sequences and state transitions. +Provide a deterministic execution harness for headless simulations (fixed timestep configuration and seeded RNG) to enable reproducible test runs. #### Technical details -- **Fixed timestep**: The adapter advances simulation by a constant interval each tick, avoiding variable frame rate effects. The timestep is configurable at initialization. -- **Seeded RNG**: All random operations use a provided seed, ensuring identical results for the same scenario. The RNG is exposed via a shared service or passed to providers. -- **Scenario scripting**: Input and event scripts are replayed identically across runs. The HeadlessInputProvider consumes events from a pre-built queue. +- **Fixed timestep**: The deterministic runner currently stores a fixed timestep value. It does not yet advance or expose a simulation clock based on this timestep. +- **Seeded RNG**: The deterministic runner initializes an RNG with the provided seed. The RNG is not yet used to drive any simulation outputs. +- **Scenario scripting**: Scripted input exists as explicit setter methods on `HeadlessInputProvider`, but there is no scenario/queue playback format yet. #### Phase requirements -- (***Complete***) Fixed timestep execution +- (***Partially complete***) Fixed timestep execution - GIVEN a configured fixed timestep interval - WHEN the simulation runs N steps - - THEN each step advances by exactly the configured interval. + - THEN the runner executes N steps (timestep is currently stored, not applied to a simulation clock). -- (***Complete***) Seeded RNG reproducibility +- (***Partially complete***) Seeded RNG reproducibility - GIVEN a fixed RNG seed - WHEN the simulation runs twice with identical inputs - - THEN the results (render commands, state transitions) are identical. + - THEN the runner completes deterministically (no non-deterministic outputs are currently produced by RNG). #### Examples @@ -585,11 +591,10 @@ public sealed class DeterministicEngineRunner ### Testing and compatibility -- All adapters must be covered by integration tests for AI, combat, and map generation scenarios. -- Tests must assert that, given the same seed and scripted events, results are stable and reproducible across runs. -- Adapters must be compatible with CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware. -- TestAdapter must expose APIs for retrieving and asserting recorded calls. -- Unit tests should cover each headless provider independently. +- Unit tests cover each headless provider independently, plus `TestAdapter` and `DeterministicEngineRunner`. +- There is currently no AI/combat/map-generation integration scenario suite in this repository. +- Adapters are designed to be compatible with CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware. +- `TestAdapter` exposes `RecordedCalls` for asserting recorded adapter lifecycle calls (provider call recording is not yet implemented). ### Performance targets @@ -599,9 +604,11 @@ public sealed class DeterministicEngineRunner ## Known issues and design concerns -- **Phase 1 dependency**: Implementation cannot proceed until all Phase 1 interfaces and DTOs are finalized. Changes to Phase 1 contracts will require updates to HeadlessAdapter and TestAdapter. -- **Scenario scripting format**: The input scripting format and playback mechanism must be well-defined before HeadlessInputProvider can be fully implemented. Consider a simple queue-based approach initially. -- **Call recording overhead**: Recording all calls may impact performance for very large scenarios. Consider providing a flag to disable recording or bounding the recording buffer. +- **Integration scenarios not implemented**: The plan references AI/combat/map-generation integration scenarios, but the repository currently only contains unit tests. +- **TestAdapter provider call recording not implemented**: `TestAdapter` currently records adapter lifecycle calls only; provider calls are delegated directly. +- **Deterministic runner is a skeleton**: `DeterministicEngineRunner` stores a fixed timestep and initializes a seeded RNG, but does not yet advance a simulation clock or use RNG to drive any outputs. +- **Scenario scripting format**: `HeadlessInputProvider` supports scripted state via setter methods, but there is no queue/script playback format yet. +- **Call recording overhead**: If/when provider-call recording is added, recording all calls may impact performance for very large scenarios. Consider bounding the recording buffer. ## See also diff --git a/src/GameEngineAdapter.Core/IAudioPlayer.cs b/src/GameEngineAdapter.Core/IAudioPlayer.cs index 3084e85..39e1b4d 100644 --- a/src/GameEngineAdapter.Core/IAudioPlayer.cs +++ b/src/GameEngineAdapter.Core/IAudioPlayer.cs @@ -6,10 +6,10 @@ namespace JohnLudlow.GameEngineAdapter.Core; public interface IAudioPlayer { /// Plays the specified audio asset. - void Play(string audioAssetId, bool loop = false); + void StartPlayback(string audioAssetId, bool loopPlayback = false); /// Stops playback of the specified audio asset. - void Stop(string audioAssetId); + void StopPlayBack(string audioAssetId); /// Sets the volume for the specified audio asset (0.0–1.0). void SetVolume(string audioAssetId, float volume); diff --git a/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs b/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs index c0475c1..7b0082c 100644 --- a/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs +++ b/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs @@ -15,11 +15,11 @@ public sealed class HeadlessAudioPlayer : IAudioPlayer _recordedCalls; /// - public void Play(string audioAssetId, bool loop = false) => - _recordedCalls.Add(("Play", audioAssetId, loop)); + public void StartPlayback(string audioAssetId, bool loopPlayback = false) => + _recordedCalls.Add(("Play", audioAssetId, loopPlayback)); /// - public void Stop(string audioAssetId) => + public void StopPlayBack(string audioAssetId) => _recordedCalls.Add(("Stop", audioAssetId, null)); /// diff --git a/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs index 98002f6..9023f9e 100644 --- a/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs +++ b/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs @@ -21,7 +21,7 @@ public void HeadlessAudioPlayer_Play_RecordsCall() var player = new HeadlessAudioPlayer(); // Act - player.Play("music_main", loop: true); + player.StartPlayback("music_main", loopPlayback: true); // Assert Assert.Single(player.RecordedCalls); @@ -38,7 +38,7 @@ public void HeadlessAudioPlayer_Play_WithDefaultLoop_RecordsFalse() var player = new HeadlessAudioPlayer(); // Act - player.Play("sfx_jump"); + player.StartPlayback("sfx_jump"); // Assert var call = player.RecordedCalls[0]; @@ -52,7 +52,7 @@ public void HeadlessAudioPlayer_Stop_RecordsCall() var player = new HeadlessAudioPlayer(); // Act - player.Stop("music_main"); + player.StopPlayBack("music_main"); // Assert Assert.Single(player.RecordedCalls); @@ -86,9 +86,9 @@ public void HeadlessAudioPlayer_MultipleOperations_RecordsAllInOrder() var player = new HeadlessAudioPlayer(); // Act - player.Play("sfx_jump"); + player.StartPlayback("sfx_jump"); player.SetVolume("sfx_jump", 0.8f); - player.Stop("sfx_jump"); + player.StopPlayBack("sfx_jump"); // Assert Assert.Equal(3, player.RecordedCalls.Count); @@ -102,8 +102,8 @@ public void HeadlessAudioPlayer_Clear_RemovesAllCalls() { // Arrange var player = new HeadlessAudioPlayer(); - player.Play("sfx_a"); - player.Stop("sfx_b"); + player.StartPlayback("sfx_a"); + player.StopPlayBack("sfx_b"); // Act player.Clear(); From 4496641c24f0ad4c519c95fe7905937a14129554 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 19:29:49 +0100 Subject: [PATCH 05/19] Add benchmark project and tests for DTO translation performance --- .github/workflows/main.yml | 41 +++-- GameEngineAdapter.slnx | 1 + actions | 2 +- ...issue-1-translator-tests-and-benchmarks.md | 151 ++++++++++++++++++ .../GameEngineAdapter.Benchmarks.csproj | 20 +++ src/GameEngineAdapter.Benchmarks/Program.cs | 9 ++ .../RenderTranslationBenchmarks.cs | 70 ++++++++ .../Translator/IFakeRenderBackend.cs | 28 ++++ .../Translator/RecordingFakeRenderBackend.cs | 41 +++++ .../Translator/TranslatingRenderProvider.cs | 41 +++++ .../TranslatingRenderProviderTests.cs | 149 +++++++++++++++++ 11 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 docs/plans/issue-1-translator-tests-and-benchmarks.md create mode 100644 src/GameEngineAdapter.Benchmarks/GameEngineAdapter.Benchmarks.csproj create mode 100644 src/GameEngineAdapter.Benchmarks/Program.cs create mode 100644 src/GameEngineAdapter.Benchmarks/RenderTranslationBenchmarks.cs create mode 100644 src/GameEngineAdapter.UnitTests/Translator/IFakeRenderBackend.cs create mode 100644 src/GameEngineAdapter.UnitTests/Translator/RecordingFakeRenderBackend.cs create mode 100644 src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProvider.cs create mode 100644 src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProviderTests.cs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 412e1cc..ee104ab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -69,22 +69,31 @@ jobs: assembly-semver: ${{ needs.build.outputs.assemblySemVer }} gitversion-full-semver: ${{ needs.build.outputs.GitVersion_FullSemVer }} - # benchmark: - # needs: build - # runs-on: windows-latest - # env: - # CONFIGURATION: Release - # permissions: - # contents: read - # issues: write - - # steps: - # - uses: actions/checkout@v5 - # with: - # lfs: true - # fetch-depth: 0 - - # - uses: ./actions/steps/benchmark + benchmark: + needs: build + runs-on: windows-latest + env: + CONFIGURATION: Release + permissions: + contents: read + issues: write + actions: read + + steps: + - uses: actions/checkout@v5 + with: + lfs: true + fetch-depth: 0 + submodules: recursive + + - uses: ./actions/steps/setup + - uses: ./actions/steps/benchmark + with: + configuration: ${{ env.CONFIGURATION }} + project: src/GameEngineAdapter.Benchmarks/GameEngineAdapter.Benchmarks.csproj + job: short + exporters: GitHub + filter: "*" lint: runs-on: windows-latest diff --git a/GameEngineAdapter.slnx b/GameEngineAdapter.slnx index c6f9d7f..27fffad 100644 --- a/GameEngineAdapter.slnx +++ b/GameEngineAdapter.slnx @@ -3,5 +3,6 @@ + diff --git a/actions b/actions index a1edd1c..15f4ded 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit a1edd1cbe8dbf9e4eee70b8c8622d5c3b5556cff +Subproject commit 15f4dede7142de3e4747dc9cac522ddd533461b0 diff --git a/docs/plans/issue-1-translator-tests-and-benchmarks.md b/docs/plans/issue-1-translator-tests-and-benchmarks.md new file mode 100644 index 0000000..e16145e --- /dev/null +++ b/docs/plans/issue-1-translator-tests-and-benchmarks.md @@ -0,0 +1,151 @@ +# Issue #1 follow-up — Translator tests and benchmarks + +## Overview + +Issue #1 is functionally complete for contract shape (interfaces + DTOs), but two acceptance criteria remain: + +1. Translator-style unit tests that verify DTOs are correctly mapped to an engine-facing call surface using fakes. +2. A benchmark suite that measures DTO translation performance (target: single-digit microseconds on hot paths). + +This plan describes how to add those two items without changing the public contract surface in `GameEngineAdapter.Core`. + +## Table of contents + +- [Issue #1 follow-up — Translator tests and benchmarks](#issue-1-follow-up--translator-tests-and-benchmarks) + - [Overview](#overview) + - [Table of contents](#table-of-contents) + - [Plan issue](#plan-issue) + - [Plan status](#plan-status) + - [Definition of terms](#definition-of-terms) + - [Architectural considerations and constraints](#architectural-considerations-and-constraints) + - [Implementation guide](#implementation-guide) + - [Plan requirements](#plan-requirements) + - [Phase 1 — Translator tests](#phase-1--translator-tests) + - [Phase 2 — Benchmarks](#phase-2--benchmarks) + - [See also](#see-also) + - [References](#references) + +## Plan issue + +- [#1](https://github.com/JohnLudlow/GameEngineAdapter/issues/1) + +## Plan status + +Completed + +## Definition of terms + +| Term | Meaning | Reference | +| ---- | ------- | --------- | +| BenchmarkDotNet | .NET microbenchmark framework that runs code repeatedly under controlled conditions to measure throughput/latency. | | +| Translator test | Unit test that verifies a DTO is translated/mapped into a lower-level call surface correctly (typically using fakes/spies). | | + +## Architectural considerations and constraints + +- **Do not change contracts**: `JohnLudlow.GameEngineAdapter.Core` is the public contract assembly. Translator tests and benchmarks should not require changes to public interfaces unless strictly necessary. +- **Keep engine-agnostic**: Translator tests should validate mapping logic without taking a dependency on a real engine SDK. +- **Avoid benchmark noise**: Benchmarks should run in Release and avoid allocations or I/O unrelated to the measured translation. +- **CI integration already scaffolded**: There is an existing composite action at `actions/steps/benchmark/action.yml` with TODO placeholders for the benchmark project path, and the workflow job is currently commented out in `.github/workflows/main.yml`. + +## Implementation guide + +### Plan requirements + +- (***Not started***) Translator tests exist for DTO→engine-call mapping + - GIVEN a fake engine call surface + - WHEN an adapter-facing provider receives DTOs + - THEN the provider calls the fake engine API with correctly translated values. + +- (***Not started***) Benchmark suite exists for DTO translation + - GIVEN a benchmark project + - WHEN the benchmark runs in Release + - THEN it reports per-operation timing for translation hot paths. + +- (***Not started***) CI can run benchmarks (optional but recommended) + - GIVEN a PR build + - WHEN the benchmark job is enabled + - THEN BenchmarkDotNet results are produced as artifacts and published in the build summary. + +### Phase 1 — Translator tests + +***Not started*** + +#### Objective + +Add unit tests that verify translation from DTOs (`SpriteDrawDto`, `TextDrawDto`, `MeshDrawDto`, `MaterialDto`, `TransformDto`) into an engine-facing API using fakes/spies. + +#### Technical details + +1. **Create a minimal fake engine call surface** inside the UnitTests project. + - Example: `IFakeRenderBackend` with methods like `DrawSprite(string spriteId, TransformDto transform, MaterialDto material, int layer)`. + - Implementation: `RecordingFakeRenderBackend` stores each call (method name + args) into a list. + +2. **Create a sample translator provider** (test-only) that implements `IRenderProvider` and forwards to the fake backend. + - Example type: `TranslatingRenderProvider : IRenderProvider`. + - Translation should be intentionally simple and explicit (pass-through of IDs/transform/material/layer). The goal is to validate mapping patterns, not to build a real engine adapter. + +3. **Write translator tests** validating: + - Ordering: calls arrive in the same order as DTO submissions. + - Fidelity: all DTO fields are forwarded without mutation. + - Frame boundaries: `BeginFrame`/`EndFrame`/`Present` call sequences can be asserted if the translator provider chooses to forward them. + +4. **Keep tests isolated** + - Do not reuse `HeadlessRenderProvider` for translator tests; that provider records DTOs but does not exercise a DTO→engine mapping. + +#### Phase requirements + +- (***Not started***) Render DTO translation is verified + - GIVEN a `TranslatingRenderProvider` backed by a recording fake backend + - WHEN `SubmitSprite`, `SubmitText`, and `SubmitMesh` are invoked + - THEN the fake backend receives equivalent calls with equivalent values. + +- (***Not started***) Material and transform objects are forwarded correctly + - GIVEN a DTO with non-default `TransformDto` and populated `MaterialDto.Uniforms` + - WHEN the DTO is submitted + - THEN the backend sees the same values. + +### Phase 2 — Benchmarks + +***Not started*** + +#### Objective + +Add a BenchmarkDotNet benchmark project that measures DTO translation performance for representative hot paths. + +#### Technical details + +1. **Create a new benchmark project** + - Path: `src/GameEngineAdapter.Benchmarks/GameEngineAdapter.Benchmarks.csproj` + - References: `GameEngineAdapter.Core` and (optionally) a small internal translation implementation shared with tests. + - Packages: `BenchmarkDotNet`. + +2. **Define benchmarks** + - Benchmark `TranslatingRenderProvider.SubmitSprite` with a fixed DTO. + - Benchmark a loop over N submissions to reduce overhead noise. + - Ensure Release configuration and `--filter *` works. + +3. **Wire up the existing CI benchmark action (optional but recommended)** + - Replace the `` placeholders in `actions/steps/benchmark/action.yml` with the actual benchmark project path. + - Uncomment/enable the `benchmark` job in `.github/workflows/main.yml` once the benchmark project exists. + +#### Phase requirements + +- (***Not started***) Benchmarks run locally + - GIVEN the benchmark project + - WHEN `dotnet run -c Release --project src/GameEngineAdapter.Benchmarks/...` is executed + - THEN BenchmarkDotNet produces a markdown results file under `BenchmarkDotNet.Artifacts/results/`. + +- (***Not started***) Benchmarks run in CI (optional) + - GIVEN the workflow benchmark job is enabled + - WHEN the CI pipeline runs + - THEN benchmark results are uploaded and included in the job summary. + +## See also + +- [Phase 1 — Interface development](./phase-1-interface-development.md) + +## References + +- BenchmarkDotNet docs: +- Existing benchmark action scaffold: `actions/steps/benchmark/action.yml` +- CI workflow scaffold: `.github/workflows/main.yml` diff --git a/src/GameEngineAdapter.Benchmarks/GameEngineAdapter.Benchmarks.csproj b/src/GameEngineAdapter.Benchmarks/GameEngineAdapter.Benchmarks.csproj new file mode 100644 index 0000000..fed2425 --- /dev/null +++ b/src/GameEngineAdapter.Benchmarks/GameEngineAdapter.Benchmarks.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + Exe + false + JohnLudlow.GameEngineAdapter.Benchmarks + + + + + + + + + + + diff --git a/src/GameEngineAdapter.Benchmarks/Program.cs b/src/GameEngineAdapter.Benchmarks/Program.cs new file mode 100644 index 0000000..e08dd2c --- /dev/null +++ b/src/GameEngineAdapter.Benchmarks/Program.cs @@ -0,0 +1,9 @@ +namespace JohnLudlow.GameEngineAdapter.Benchmarks; + +using BenchmarkDotNet.Running; + +internal static class Program +{ + public static void Main(string[] args) => + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +} diff --git a/src/GameEngineAdapter.Benchmarks/RenderTranslationBenchmarks.cs b/src/GameEngineAdapter.Benchmarks/RenderTranslationBenchmarks.cs new file mode 100644 index 0000000..fd86a23 --- /dev/null +++ b/src/GameEngineAdapter.Benchmarks/RenderTranslationBenchmarks.cs @@ -0,0 +1,70 @@ +namespace JohnLudlow.GameEngineAdapter.Benchmarks; + +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using JohnLudlow.GameEngineAdapter.Core; + +[MemoryDiagnoser] +public class RenderTranslationBenchmarks +{ + private const int Iterations = 1000; + + private TranslatingRenderProvider _provider = null!; + private SpriteDrawDto _sprite; + + [GlobalSetup] + public void Setup() + { + var backend = new NoOpFakeRenderBackend(); + _provider = new TranslatingRenderProvider(backend); + + var transform = new TransformDto( + X: 1f, Y: 2f, Z: 3f, + RotationX: 10f, RotationY: 20f, RotationZ: 30f, + ScaleX: 1f, ScaleY: 2f, ScaleZ: 3f); + + var uniforms = new Dictionary + { + ["u_color"] = "red", + ["u_alpha"] = 0.75f, + }; + + var material = new MaterialDto("shader_sprite", uniforms, [0, 2]); + _sprite = new SpriteDrawDto("sprite_1", transform, material, Layer: 0); + } + + [Benchmark] + public void SubmitSprite_Single() => + _provider.SubmitSprite(in _sprite); + + [Benchmark] + public void SubmitSprite_Loop1000() + { + for (var i = 0; i < Iterations; i++) + { + _provider.SubmitSprite(in _sprite); + } + } + + private interface IFakeRenderBackend + { + void DrawSprite(string spriteId, in TransformDto transform, in MaterialDto material, int layer); + } + + private sealed class NoOpFakeRenderBackend : IFakeRenderBackend + { + public void DrawSprite(string spriteId, in TransformDto transform, in MaterialDto material, int layer) + { + } + } + + private sealed class TranslatingRenderProvider(IFakeRenderBackend backend) + { + public void SubmitSprite(in SpriteDrawDto dto) + { + var transform = dto.Transform; + var material = dto.Material; + backend.DrawSprite(dto.SpriteId, in transform, in material, dto.Layer); + } + } +} diff --git a/src/GameEngineAdapter.UnitTests/Translator/IFakeRenderBackend.cs b/src/GameEngineAdapter.UnitTests/Translator/IFakeRenderBackend.cs new file mode 100644 index 0000000..a99031e --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/Translator/IFakeRenderBackend.cs @@ -0,0 +1,28 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests.Translator; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Minimal fake engine-facing render API used by translator tests. +/// This allows verifying DTO-to-engine-call mapping without depending on a real engine SDK. +/// +public interface IFakeRenderBackend +{ + void BeginFrame(in CameraDescriptor camera); + + void DrawSprite(string spriteId, in TransformDto transform, in MaterialDto material, int layer); + + void DrawText( + string text, + in TransformDto transform, + string fontId, + float fontSize, + in MaterialDto material, + int layer); + + void DrawMesh(string meshId, in TransformDto transform, in MaterialDto material, int layer); + + void EndFrame(); + + void Present(); +} diff --git a/src/GameEngineAdapter.UnitTests/Translator/RecordingFakeRenderBackend.cs b/src/GameEngineAdapter.UnitTests/Translator/RecordingFakeRenderBackend.cs new file mode 100644 index 0000000..456d891 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/Translator/RecordingFakeRenderBackend.cs @@ -0,0 +1,41 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests.Translator; + +using JohnLudlow.GameEngineAdapter.Core; + +public readonly record struct FakeBackendCall( + string MethodName, + IReadOnlyList Arguments); + +/// +/// Fake render backend that records all calls for assertion. +/// +public sealed class RecordingFakeRenderBackend : IFakeRenderBackend +{ + private readonly List _calls = []; + + public IReadOnlyList Calls => _calls; + + public void BeginFrame(in CameraDescriptor camera) => + _calls.Add(new FakeBackendCall("BeginFrame", [camera])); + + public void DrawSprite(string spriteId, in TransformDto transform, in MaterialDto material, int layer) => + _calls.Add(new FakeBackendCall("DrawSprite", [spriteId, transform, material, layer])); + + public void DrawText( + string text, + in TransformDto transform, + string fontId, + float fontSize, + in MaterialDto material, + int layer) => + _calls.Add(new FakeBackendCall("DrawText", [text, transform, fontId, fontSize, material, layer])); + + public void DrawMesh(string meshId, in TransformDto transform, in MaterialDto material, int layer) => + _calls.Add(new FakeBackendCall("DrawMesh", [meshId, transform, material, layer])); + + public void EndFrame() => + _calls.Add(new FakeBackendCall("EndFrame", [])); + + public void Present() => + _calls.Add(new FakeBackendCall("Present", [])); +} diff --git a/src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProvider.cs b/src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProvider.cs new file mode 100644 index 0000000..1e59ede --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProvider.cs @@ -0,0 +1,41 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests.Translator; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Test-only provider that translates adapter DTO submissions into an engine-facing backend API. +/// Translation is intentionally explicit and minimal (pass-through) to validate mapping patterns. +/// +public sealed class TranslatingRenderProvider(IFakeRenderBackend backend) : IRenderProvider +{ + public FrameScope BeginFrame(in CameraDescriptor camera) + { + backend.BeginFrame(in camera); + return new FrameScope(); + } + + public void SubmitSprite(in SpriteDrawDto dto) + { + var transform = dto.Transform; + var material = dto.Material; + backend.DrawSprite(dto.SpriteId, in transform, in material, dto.Layer); + } + + public void SubmitText(in TextDrawDto dto) + { + var transform = dto.Transform; + var material = dto.Material; + backend.DrawText(dto.Text, in transform, dto.FontId, dto.FontSize, in material, dto.Layer); + } + + public void SubmitMesh(in MeshDrawDto dto) + { + var transform = dto.Transform; + var material = dto.Material; + backend.DrawMesh(dto.MeshId, in transform, in material, dto.Layer); + } + + public void EndFrame() => backend.EndFrame(); + + public void Present() => backend.Present(); +} diff --git a/src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProviderTests.cs b/src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProviderTests.cs new file mode 100644 index 0000000..1f7943e --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/Translator/TranslatingRenderProviderTests.cs @@ -0,0 +1,149 @@ +namespace JohnLudlow.GameEngineAdapter.UnitTests.Translator; + +using System.Collections.Generic; +using JohnLudlow.GameEngineAdapter.Core; + +public class TranslatingRenderProviderTests +{ + private static TransformDto MakeTransform(float x = 1f, float y = 2f, float z = 3f) => + new(x, y, z, RotationX: 10f, RotationY: 20f, RotationZ: 30f, ScaleX: 1f, ScaleY: 2f, ScaleZ: 3f); + + private static MaterialDto MakeMaterial(string shaderId) + { + var uniforms = new Dictionary + { + ["u_color"] = "red", + ["u_alpha"] = 0.75f, + }; + + return new MaterialDto(shaderId, uniforms, [0, 2]); + } + + private static CameraDescriptor MakeCamera() => + new(ProjectionType.Orthographic, FieldOfViewDegrees: 0f, OrthographicSize: 10f, AspectRatio: 16f / 9f, NearPlane: 0.1f, FarPlane: 100f); + + [Fact] + public void SubmitSprite_TranslatesToBackendCall_WithEquivalentValues() + { + // Arrange + var backend = new RecordingFakeRenderBackend(); + var provider = new TranslatingRenderProvider(backend); + + var transform = MakeTransform(); + var material = MakeMaterial("shader_sprite"); + var dto = new SpriteDrawDto("sprite_1", transform, material, Layer: 42); + + // Act + provider.SubmitSprite(in dto); + + // Assert + Assert.Single(backend.Calls); + var call = backend.Calls[0]; + Assert.Equal("DrawSprite", call.MethodName); + + Assert.Equal("sprite_1", call.Arguments[0]); + Assert.Equal(transform, call.Arguments[1]); + Assert.Equal(material, call.Arguments[2]); + Assert.Equal(42, call.Arguments[3]); + + // And ensure reference-typed members are forwarded (not deep-copied) + var forwardedMaterial = (MaterialDto)call.Arguments[2]!; + Assert.Same(material.Uniforms, forwardedMaterial.Uniforms); + } + + [Fact] + public void SubmitText_TranslatesToBackendCall_WithEquivalentValues() + { + // Arrange + var backend = new RecordingFakeRenderBackend(); + var provider = new TranslatingRenderProvider(backend); + + var transform = MakeTransform(x: 9f, y: 8f, z: 7f); + var material = MakeMaterial("shader_text"); + var dto = new TextDrawDto("Hello", transform, FontId: "font_main", FontSize: 13.5f, material, Layer: 3); + + // Act + provider.SubmitText(in dto); + + // Assert + Assert.Single(backend.Calls); + var call = backend.Calls[0]; + Assert.Equal("DrawText", call.MethodName); + + Assert.Equal("Hello", call.Arguments[0]); + Assert.Equal(transform, call.Arguments[1]); + Assert.Equal("font_main", call.Arguments[2]); + Assert.Equal(13.5f, call.Arguments[3]); + Assert.Equal(material, call.Arguments[4]); + Assert.Equal(3, call.Arguments[5]); + } + + [Fact] + public void SubmitMesh_TranslatesToBackendCall_WithEquivalentValues() + { + // Arrange + var backend = new RecordingFakeRenderBackend(); + var provider = new TranslatingRenderProvider(backend); + + var transform = MakeTransform(); + var material = MakeMaterial("shader_mesh"); + var dto = new MeshDrawDto("mesh_tree", transform, material, Layer: 1); + + // Act + provider.SubmitMesh(in dto); + + // Assert + Assert.Single(backend.Calls); + var call = backend.Calls[0]; + Assert.Equal("DrawMesh", call.MethodName); + + Assert.Equal("mesh_tree", call.Arguments[0]); + Assert.Equal(transform, call.Arguments[1]); + Assert.Equal(material, call.Arguments[2]); + Assert.Equal(1, call.Arguments[3]); + } + + [Fact] + public void MultipleSubmits_AreRecordedInOrder() + { + // Arrange + var backend = new RecordingFakeRenderBackend(); + var provider = new TranslatingRenderProvider(backend); + + // Act + var sprite = new SpriteDrawDto("s", MakeTransform(), MakeMaterial("m1"), Layer: 0); + var text = new TextDrawDto("t", MakeTransform(), "f", 12f, MakeMaterial("m2"), Layer: 1); + var mesh = new MeshDrawDto("m", MakeTransform(), MakeMaterial("m3"), Layer: 2); + + provider.SubmitSprite(in sprite); + provider.SubmitText(in text); + provider.SubmitMesh(in mesh); + + // Assert + Assert.Equal(3, backend.Calls.Count); + Assert.Equal("DrawSprite", backend.Calls[0].MethodName); + Assert.Equal("DrawText", backend.Calls[1].MethodName); + Assert.Equal("DrawMesh", backend.Calls[2].MethodName); + } + + [Fact] + public void FrameBoundaryCalls_AreForwarded() + { + // Arrange + var backend = new RecordingFakeRenderBackend(); + var provider = new TranslatingRenderProvider(backend); + var camera = MakeCamera(); + + // Act + using var scope = provider.BeginFrame(in camera); + provider.EndFrame(); + provider.Present(); + + // Assert + Assert.Equal(3, backend.Calls.Count); + Assert.Equal("BeginFrame", backend.Calls[0].MethodName); + Assert.Equal(camera, backend.Calls[0].Arguments[0]); + Assert.Equal("EndFrame", backend.Calls[1].MethodName); + Assert.Equal("Present", backend.Calls[2].MethodName); + } +} From 30a2f6c99a52f21ee21048874df8946568e4dde6 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 20:01:21 +0100 Subject: [PATCH 06/19] Update subproject commit reference in actions --- actions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions b/actions index 15f4ded..f75a737 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit 15f4dede7142de3e4747dc9cac522ddd533461b0 +Subproject commit f75a7378f319508dbd05f3995a1cfa35946bb239 From 598bc3c0932877d634294f2c5becb354b9ce0233 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 20:04:16 +0100 Subject: [PATCH 07/19] Uncomment push trigger in GitHub Actions workflow --- .github/workflows/main.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee104ab..72ea97d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,8 +4,7 @@ name: Build and Test on: - # TODO: Uncomment this line - # push: + push: pull_request: types: [opened, synchronize, reopened] From 9497d5a672877a3dab6a81d26c528a674b29124b Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 21:09:53 +0100 Subject: [PATCH 08/19] Update actions based on comments in PR --- .github/workflows/main.yml | 2 ++ actions | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 72ea97d..5969839 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,6 +45,8 @@ jobs: - id: tag-commit uses: ./actions/steps/git/tag-commit + with: + version: ${{ steps.setup.outputs.GitVersion_FullSemVer }} unit-test: needs: build diff --git a/actions b/actions index f75a737..7904878 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit f75a7378f319508dbd05f3995a1cfa35946bb239 +Subproject commit 7904878a78cfc46ace492da62b593d477450e5bf From b97fda9b966782ca5e01e4adab5e83c22ae1d948 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 21:11:07 +0100 Subject: [PATCH 09/19] Update actions to test --- actions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions b/actions index 7904878..8d05b51 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit 7904878a78cfc46ace492da62b593d477450e5bf +Subproject commit 8d05b51d727caf2b1edf0839eb5187f752aa6811 From 993857698013b9480107bf29f324ee3fe82da09e Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 21:15:27 +0100 Subject: [PATCH 10/19] Update actions --- actions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions b/actions index 8d05b51..e0ffdba 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit 8d05b51d727caf2b1edf0839eb5187f752aa6811 +Subproject commit e0ffdba8e48caa704041f799ab427ceba5b17011 From 952aa1660cea5a3e754e04d9f7182d00973cfcc4 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 21:26:27 +0100 Subject: [PATCH 11/19] Update actions --- actions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions b/actions index e0ffdba..5bef35b 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit e0ffdba8e48caa704041f799ab427ceba5b17011 +Subproject commit 5bef35b8e5a94b425ff6cd058ea6195b3b6ad401 From a1c8799613d4a6ef02ccbacb2a540777dcb8c7da Mon Sep 17 00:00:00 2001 From: JohnLudlow Date: Tue, 21 Apr 2026 21:34:24 +0100 Subject: [PATCH 12/19] Update docs/plans/phase-2-headless-adapter-development.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/plans/phase-2-headless-adapter-development.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/phase-2-headless-adapter-development.md b/docs/plans/phase-2-headless-adapter-development.md index b05c24b..8e7da6c 100644 --- a/docs/plans/phase-2-headless-adapter-development.md +++ b/docs/plans/phase-2-headless-adapter-development.md @@ -387,12 +387,12 @@ public sealed class HeadlessAudioPlayer : IAudioPlayer _recordedCalls; /// - public void Play(string audioAssetId, bool loop = false) => - _recordedCalls.Add(("Play", audioAssetId, loop)); + public void StartPlayback(string audioAssetId, bool loop = false) => + _recordedCalls.Add(("StartPlayback", audioAssetId, loop)); /// - public void Stop(string audioAssetId) => - _recordedCalls.Add(("Stop", audioAssetId, null)); + public void StopPlayBack(string audioAssetId) => + _recordedCalls.Add(("StopPlayBack", audioAssetId, null)); /// public void SetVolume(string audioAssetId, float volume) => From b6b37bbd4b7de57d45eddefe694c202709fb94b7 Mon Sep 17 00:00:00 2001 From: JohnLudlow Date: Tue, 21 Apr 2026 21:34:39 +0100 Subject: [PATCH 13/19] Update src/GameEngineAdapter.Core/IAudioPlayer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/GameEngineAdapter.Core/IAudioPlayer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameEngineAdapter.Core/IAudioPlayer.cs b/src/GameEngineAdapter.Core/IAudioPlayer.cs index 39e1b4d..974e5fa 100644 --- a/src/GameEngineAdapter.Core/IAudioPlayer.cs +++ b/src/GameEngineAdapter.Core/IAudioPlayer.cs @@ -9,7 +9,7 @@ public interface IAudioPlayer void StartPlayback(string audioAssetId, bool loopPlayback = false); /// Stops playback of the specified audio asset. - void StopPlayBack(string audioAssetId); + void StopPlayback(string audioAssetId); /// Sets the volume for the specified audio asset (0.0–1.0). void SetVolume(string audioAssetId, float volume); From 634e8d7f75998eaa850efb7f0c3204a3fd0ff0a3 Mon Sep 17 00:00:00 2001 From: JohnLudlow Date: Tue, 21 Apr 2026 21:35:00 +0100 Subject: [PATCH 14/19] Update src/GameEngineAdapter.Core/IRenderProvider.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/GameEngineAdapter.Core/IRenderProvider.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/GameEngineAdapter.Core/IRenderProvider.cs b/src/GameEngineAdapter.Core/IRenderProvider.cs index 76327a0..b59ad2a 100644 --- a/src/GameEngineAdapter.Core/IRenderProvider.cs +++ b/src/GameEngineAdapter.Core/IRenderProvider.cs @@ -9,7 +9,10 @@ public interface IRenderProvider /// Starts a new render frame with the specified camera configuration. /// /// Camera configuration for the frame. - /// A scope that finalizes the frame when disposed. + /// + /// A scope representing the active frame. Disposing this scope does not finalize or present the frame; + /// callers must explicitly invoke and . + /// FrameScope BeginFrame(in CameraDescriptor camera); /// Submits a sprite for rendering. From d9a0f5d1e3ea640bd0e99be62ddb8ba54a4200a6 Mon Sep 17 00:00:00 2001 From: JohnLudlow Date: Tue, 21 Apr 2026 21:35:12 +0100 Subject: [PATCH 15/19] Update src/GameEngineAdapter.Core/FrameScope.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/GameEngineAdapter.Core/FrameScope.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/GameEngineAdapter.Core/FrameScope.cs b/src/GameEngineAdapter.Core/FrameScope.cs index 685e4e7..4ac35d3 100644 --- a/src/GameEngineAdapter.Core/FrameScope.cs +++ b/src/GameEngineAdapter.Core/FrameScope.cs @@ -1,10 +1,11 @@ namespace JohnLudlow.GameEngineAdapter.Core; /// -/// Disposable scope for a single render frame. Disposing finalizes the frame. +/// Disposable marker scope for a single render frame. +/// Disposing this scope currently performs no action. /// public readonly record struct FrameScope : IDisposable { - /// Finalizes the current frame. - public void Dispose() { /* adapter-specific frame end logic */ } + /// No-op marker disposal for the current frame scope. + public void Dispose() { /* intentionally no-op marker scope */ } } From e3cdd5eff93f571a0151897a3274f27a2a5a8849 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:36:23 +0000 Subject: [PATCH 16/19] fix: update HeadlessAdapter class summary to accurately reflect provider behavior Agent-Logs-Url: https://github.com/JohnLudlow/GameEngineAdapter/sessions/2875168b-27a7-436f-a35c-680e40379f91 Co-authored-by: JohnLudlow <1769289+JohnLudlow@users.noreply.github.com> --- src/GameEngineAdapter.Headless/HeadlessAdapter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameEngineAdapter.Headless/HeadlessAdapter.cs b/src/GameEngineAdapter.Headless/HeadlessAdapter.cs index 46bb96c..65df726 100644 --- a/src/GameEngineAdapter.Headless/HeadlessAdapter.cs +++ b/src/GameEngineAdapter.Headless/HeadlessAdapter.cs @@ -4,7 +4,7 @@ namespace JohnLudlow.GameEngineAdapter.Headless; /// /// Simulates engine operations for headless, deterministic testing. -/// Records all provider calls for verification. +/// Exposes provider instances; individual providers may record their own calls for verification. /// public sealed class HeadlessAdapter : IEngineAdapter { From 57d2e0cd4239299e2572f0f8a4e7a40e2ab6bfd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:41:18 +0000 Subject: [PATCH 17/19] fix: replace List with typed lists in HeadlessRenderProvider to avoid boxing; fix main.yml versioning step references Agent-Logs-Url: https://github.com/JohnLudlow/GameEngineAdapter/sessions/a694adc4-8c6a-4222-aaf5-116f9a36a870 Co-authored-by: JohnLudlow <1769289+JohnLudlow@users.noreply.github.com> --- .github/workflows/main.yml | 6 ++--- .../HeadlessAudioPlayer.cs | 2 +- .../HeadlessRenderProvider.cs | 27 ++++++++++++++----- .../DeterministicEngineRunnerTests.cs | 4 ++- .../HeadlessAudioPlayerTests.cs | 6 ++--- .../HeadlessRenderProviderTests.cs | 24 ++++++++++------- 6 files changed, 45 insertions(+), 24 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5969839..f374816 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,9 +23,9 @@ jobs: contents: write pull-requests: write outputs: - assemblySemVer: ${{ steps.version_step.outputs.assemblySemVer }} - GitVersion_FullSemVer: ${{ steps.version_step.outputs.GitVersion_FullSemVer }} - semVer: ${{ steps.version_step.outputs.semVer }} + assemblySemVer: ${{ steps.setup.outputs.assemblySemVer }} + GitVersion_FullSemVer: ${{ steps.setup.outputs.GitVersion_FullSemVer }} + semVer: ${{ steps.setup.outputs.semVer }} steps: - uses: actions/checkout@v5 diff --git a/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs b/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs index 7b0082c..c95912c 100644 --- a/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs +++ b/src/GameEngineAdapter.Headless/HeadlessAudioPlayer.cs @@ -19,7 +19,7 @@ public void StartPlayback(string audioAssetId, bool loopPlayback = false) => _recordedCalls.Add(("Play", audioAssetId, loopPlayback)); /// - public void StopPlayBack(string audioAssetId) => + public void StopPlayback(string audioAssetId) => _recordedCalls.Add(("Stop", audioAssetId, null)); /// diff --git a/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs b/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs index 5ff3e2d..37d2475 100644 --- a/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs +++ b/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs @@ -7,22 +7,30 @@ namespace JohnLudlow.GameEngineAdapter.Headless; /// public sealed class HeadlessRenderProvider : IRenderProvider { - private readonly List _recordedCommands = []; + private readonly List _recordedSprites = []; + private readonly List _recordedTexts = []; + private readonly List _recordedMeshes = []; - /// Gets the list of recorded render commands. - public IReadOnlyList RecordedCommands => _recordedCommands; + /// Gets the list of recorded sprite draw commands. + public IReadOnlyList RecordedSprites => _recordedSprites; + + /// Gets the list of recorded text draw commands. + public IReadOnlyList RecordedTexts => _recordedTexts; + + /// Gets the list of recorded mesh draw commands. + public IReadOnlyList RecordedMeshes => _recordedMeshes; /// public FrameScope BeginFrame(in CameraDescriptor camera) => new(); /// - public void SubmitSprite(in SpriteDrawDto dto) => _recordedCommands.Add(dto); + public void SubmitSprite(in SpriteDrawDto dto) => _recordedSprites.Add(dto); /// - public void SubmitText(in TextDrawDto dto) => _recordedCommands.Add(dto); + public void SubmitText(in TextDrawDto dto) => _recordedTexts.Add(dto); /// - public void SubmitMesh(in MeshDrawDto dto) => _recordedCommands.Add(dto); + public void SubmitMesh(in MeshDrawDto dto) => _recordedMeshes.Add(dto); /// public void EndFrame() { } @@ -31,5 +39,10 @@ public void EndFrame() { } public void Present() { } /// Clears all recorded commands. - public void Clear() => _recordedCommands.Clear(); + public void Clear() + { + _recordedSprites.Clear(); + _recordedTexts.Clear(); + _recordedMeshes.Clear(); + } } \ No newline at end of file diff --git a/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs b/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs index aa8d5e5..e22c59e 100644 --- a/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs +++ b/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs @@ -20,7 +20,9 @@ public void DeterministicEngineRunner_Run_ZeroSteps_NoCommandsSubmitted() runner.Run(0); // Assert - Assert.Empty(renderProvider.RecordedCommands); + Assert.Empty(renderProvider.RecordedSprites); + Assert.Empty(renderProvider.RecordedTexts); + Assert.Empty(renderProvider.RecordedMeshes); } [Fact] diff --git a/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs index 9023f9e..9770c44 100644 --- a/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs +++ b/src/GameEngineAdapter.UnitTests/HeadlessAudioPlayerTests.cs @@ -52,7 +52,7 @@ public void HeadlessAudioPlayer_Stop_RecordsCall() var player = new HeadlessAudioPlayer(); // Act - player.StopPlayBack("music_main"); + player.StopPlayback("music_main"); // Assert Assert.Single(player.RecordedCalls); @@ -88,7 +88,7 @@ public void HeadlessAudioPlayer_MultipleOperations_RecordsAllInOrder() // Act player.StartPlayback("sfx_jump"); player.SetVolume("sfx_jump", 0.8f); - player.StopPlayBack("sfx_jump"); + player.StopPlayback("sfx_jump"); // Assert Assert.Equal(3, player.RecordedCalls.Count); @@ -103,7 +103,7 @@ public void HeadlessAudioPlayer_Clear_RemovesAllCalls() // Arrange var player = new HeadlessAudioPlayer(); player.StartPlayback("sfx_a"); - player.StopPlayBack("sfx_b"); + player.StopPlayback("sfx_b"); // Act player.Clear(); diff --git a/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs b/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs index 5746570..cd90d36 100644 --- a/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs +++ b/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs @@ -22,7 +22,9 @@ public void HeadlessRenderProvider_RecordedCommands_InitiallyEmpty() var provider = new HeadlessRenderProvider(); // Assert - Assert.Empty(provider.RecordedCommands); + Assert.Empty(provider.RecordedSprites); + Assert.Empty(provider.RecordedTexts); + Assert.Empty(provider.RecordedMeshes); } [Fact] @@ -36,8 +38,8 @@ public void HeadlessRenderProvider_SubmitSprite_RecordsCommand() provider.SubmitSprite(dto); // Assert - Assert.Single(provider.RecordedCommands); - Assert.Equal(dto, provider.RecordedCommands[0]); + Assert.Single(provider.RecordedSprites); + Assert.Equal(dto, provider.RecordedSprites[0]); } [Fact] @@ -51,8 +53,8 @@ public void HeadlessRenderProvider_SubmitText_RecordsCommand() provider.SubmitText(dto); // Assert - Assert.Single(provider.RecordedCommands); - Assert.Equal(dto, provider.RecordedCommands[0]); + Assert.Single(provider.RecordedTexts); + Assert.Equal(dto, provider.RecordedTexts[0]); } [Fact] @@ -66,8 +68,8 @@ public void HeadlessRenderProvider_SubmitMesh_RecordsCommand() provider.SubmitMesh(dto); // Assert - Assert.Single(provider.RecordedCommands); - Assert.Equal(dto, provider.RecordedCommands[0]); + Assert.Single(provider.RecordedMeshes); + Assert.Equal(dto, provider.RecordedMeshes[0]); } [Fact] @@ -82,7 +84,9 @@ public void HeadlessRenderProvider_MultipleSubmits_RecordsAllCommands() provider.SubmitText(new TextDrawDto("Hi", MakeTransform(), "f", 10f, MakeMaterial(), 2)); // Assert - Assert.Equal(3, provider.RecordedCommands.Count); + Assert.Equal(2, provider.RecordedSprites.Count); + Assert.Single(provider.RecordedTexts); + Assert.Empty(provider.RecordedMeshes); } [Fact] @@ -97,7 +101,9 @@ public void HeadlessRenderProvider_Clear_RemovesAllCommands() provider.Clear(); // Assert - Assert.Empty(provider.RecordedCommands); + Assert.Empty(provider.RecordedSprites); + Assert.Empty(provider.RecordedTexts); + Assert.Empty(provider.RecordedMeshes); } [Fact] From 0109f8c05ef1914b2da5fe57e156accda9fb268b Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 22:24:55 +0100 Subject: [PATCH 18/19] Update based on comments --- docs/plans/phase-1-interface-development.md | 4 ++-- .../HeadlessInputProvider.cs | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/plans/phase-1-interface-development.md b/docs/plans/phase-1-interface-development.md index 7f6423f..4a8264a 100644 --- a/docs/plans/phase-1-interface-development.md +++ b/docs/plans/phase-1-interface-development.md @@ -252,10 +252,10 @@ namespace JohnLudlow.GameEngineAdapter.Core; public interface IAudioPlayer { /// Plays the specified audio asset. - void Play(string audioAssetId, bool loop = false); + void StartPlayback(string audioAssetId, bool loopPlayback = false); /// Stops playback of the specified audio asset. - void Stop(string audioAssetId); + void StopPlayback(string audioAssetId); /// Sets the volume for the specified audio asset (0.0–1.0). void SetVolume(string audioAssetId, float volume); diff --git a/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs b/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs index d3d58a9..00faf39 100644 --- a/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs +++ b/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs @@ -3,7 +3,8 @@ namespace JohnLudlow.GameEngineAdapter.Headless; using JohnLudlow.GameEngineAdapter.Core; /// -/// Simulates input via a queue of scripted events for deterministic playback. +/// Simulates input by maintaining scripted state (keys held, buttons held, mouse position) +/// for deterministic testing. /// public sealed class HeadlessInputProvider : IInputProvider { @@ -12,25 +13,25 @@ public sealed class HeadlessInputProvider : IInputProvider private (float X, float Y) _mousePosition; /// - /// Enqueues a key-down event for the specified key. + /// Sets the specified key as held down. /// /// The key identifier. public void ScriptKeyDown(string key) => _keysDown.Add(key); /// - /// Enqueues a key-up event for the specified key. + /// Releases the specified key. /// /// The key identifier. public void ScriptKeyUp(string key) => _keysDown.Remove(key); /// - /// Enqueues a mouse button down event. + /// Sets the specified mouse button as held down. /// /// The mouse button index. public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button); /// - /// Enqueues a mouse button up event. + /// Releases the specified mouse button. /// /// The mouse button index. public void ScriptMouseButtonUp(int button) => _buttonsDown.Remove(button); From b4573d35e558bcbae23e0f1508f00c23d1520e77 Mon Sep 17 00:00:00 2001 From: John Ludlow Date: Tue, 21 Apr 2026 22:36:27 +0100 Subject: [PATCH 19/19] Update parallel docs --- .../phase-2-headless-adapter-development.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/plans/phase-2-headless-adapter-development.md b/docs/plans/phase-2-headless-adapter-development.md index 8e7da6c..230cb37 100644 --- a/docs/plans/phase-2-headless-adapter-development.md +++ b/docs/plans/phase-2-headless-adapter-development.md @@ -225,7 +225,8 @@ namespace JohnLudlow.GameEngineAdapter.Headless; using JohnLudlow.GameEngineAdapter.Core; /// -/// Simulates input via a queue of scripted events for deterministic playback. +/// Simulates input by maintaining scripted state (keys held, buttons held, mouse position) +/// for deterministic testing. /// public sealed class HeadlessInputProvider : IInputProvider { @@ -234,25 +235,25 @@ public sealed class HeadlessInputProvider : IInputProvider private (float X, float Y) _mousePosition; /// - /// Enqueues a key-down event for the specified key. + /// Sets the specified key as held down. /// /// The key identifier. public void ScriptKeyDown(string key) => _keysDown.Add(key); /// - /// Enqueues a key-up event for the specified key. + /// Releases the specified key. /// /// The key identifier. public void ScriptKeyUp(string key) => _keysDown.Remove(key); /// - /// Enqueues a mouse button down event. + /// Sets the specified mouse button as held down. /// /// The mouse button index. public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button); /// - /// Enqueues a mouse button up event. + /// Releases the specified mouse button. /// /// The mouse button index. public void ScriptMouseButtonUp(int button) => _buttonsDown.Remove(button); @@ -387,12 +388,12 @@ public sealed class HeadlessAudioPlayer : IAudioPlayer _recordedCalls; /// - public void StartPlayback(string audioAssetId, bool loop = false) => - _recordedCalls.Add(("StartPlayback", audioAssetId, loop)); + public void StartPlayback(string audioAssetId, bool loopPlayback = false) => + _recordedCalls.Add(("StartPlayback", audioAssetId, loopPlayback)); /// - public void StopPlayBack(string audioAssetId) => - _recordedCalls.Add(("StopPlayBack", audioAssetId, null)); + public void StopPlayback(string audioAssetId) => + _recordedCalls.Add(("StopPlayback", audioAssetId, null)); /// public void SetVolume(string audioAssetId, float volume) =>