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/.github/workflows/main.yml b/.github/workflows/main.yml index 412e1cc..f374816 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] @@ -24,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 @@ -46,6 +45,8 @@ jobs: - id: tag-commit uses: ./actions/steps/git/tag-commit + with: + version: ${{ steps.setup.outputs.GitVersion_FullSemVer }} unit-test: needs: build @@ -69,22 +70,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 7600562..27fffad 100644 --- a/GameEngineAdapter.slnx +++ b/GameEngineAdapter.slnx @@ -1,6 +1,8 @@ - - - - - - + + + + + + + + diff --git a/actions b/actions index a1edd1c..5bef35b 160000 --- a/actions +++ b/actions @@ -1 +1 @@ -Subproject commit a1edd1cbe8dbf9e4eee70b8c8622d5c3b5556cff +Subproject commit 5bef35b8e5a94b425ff6cd058ea6195b3b6ad401 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/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..4a8264a 100644 --- a/docs/plans/phase-1-interface-development.md +++ b/docs/plans/phase-1-interface-development.md @@ -9,32 +9,43 @@ 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 -Not started +- [#2](https://github.com/JohnLudlow/GameEngineAdapter/issues/2) + +## Plan status + +Complete (interfaces and DTOs implemented) ## Definition of terms | 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,126 +56,397 @@ Not started ## Implementation guide -### Feature requirements +### 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. -### Technical details +### Phase 1 — Adapter contracts and DTOs + +***Complete*** + +#### 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. -- 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. +#### Technical details + +- 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. -### Phase requirements +#### Type definitions -- (***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. +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. -### API examples (minimal) +The snippets are retained as illustrative examples; prefer the source files as the canonical definitions. + +##### EngineConfig ```csharp -// Example adapter root interface -public interface IEngineAdapter : IDisposable +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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); +``` + +##### FrameScope + +```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Disposable scope for a single render frame. Disposing finalizes the frame. +/// +public readonly record struct FrameScope : IDisposable { - EngineCapabilities Capabilities { get; } - Task InitializeAsync(EngineConfig config, CancellationToken ct = default); - Task ShutdownAsync(CancellationToken ct = default); + /// Finalizes the current frame. + public void Dispose() { /* adapter-specific frame end logic */ } +} +``` + +##### SpriteDrawDto + +```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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); +``` - // Provider accessors - adapters expose provider instances for rendering, input, UI and assets - IRenderProvider GetRenderProvider(); - IInputProvider GetInputProvider(); - IUserInterfaceProvider GetUserInterfaceProvider(); - IAssetProvider GetAssetProvider(); +##### TextDrawDto + +```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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 +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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 +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(); } ``` +##### IUserInterfaceProvider + ```csharp -// Engine capabilities and camera descriptor examples -public readonly struct EngineCapabilities +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Adapter-owned provider for user interface operations. +/// +public interface IUserInterfaceProvider { - 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; + // Members to be defined based on UI requirements. } +``` + +##### IAssetProvider + +```csharp +namespace JohnLudlow.GameEngineAdapter.Core; -// Example of a capabilities variant used by headless/test adapters -public readonly struct HeadlessEngineCapabilities +/// +/// Adapter-owned provider for asset lifecycle management, caching, and querying. +/// Does not load assets directly — delegates to for I/O. +/// +public interface IAssetProvider { - public readonly EngineCapabilities Base; - public readonly bool SupportsOffscreenRendering; // offscreen frame encoding / trace capture - public readonly bool DeterministicTick; // indicates deterministic simulation support + /// 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(); } +``` -public enum ProjectionType { Orthographic, Perspective } +##### IAudioPlayer -public readonly struct CameraDescriptor +```csharp +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Interface for audio playback (play, stop, volume control). +/// +public interface IAudioPlayer { - 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 + /// Plays the specified audio asset. + void StartPlayback(string audioAssetId, bool loopPlayback = false); + + /// Stops playback of the specified audio asset. + void StopPlayback(string audioAssetId); + + /// Sets the volume for the specified audio asset (0.0–1.0). + void SetVolume(string audioAssetId, float volume); } ``` +##### IAssetLoader + +```csharp +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); +} +``` + +#### Phase requirements + +- (***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. + - Note: contract mismatch diagnostics are not yet implemented in this repository. + +#### Examples + ```csharp -// Example render context and material DTOs +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 audio player for audio playback. + IAudioPlayer AudioPlayer { get; } +} +``` + +```csharp +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); + +/// +/// 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 } + +/// +/// 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 +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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; -} +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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,21 +455,25 @@ public readonly struct MaterialDto ### DTO guidelines -- Use compact DTOs (prefer `struct`) for draw lists to reduce GC pressure. -- Provide a small set of primitive types (Sprite, Text, Rect, MeshReference, MaterialDescriptor). +- 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, MaterialDto). - Include an explicit `Transform` and `Layer`/`SortKey` for deterministic ordering. ### Testing and compatibility - 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 - 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 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 - Parent plan: [Engine Decoupling](https://github.com/JohnLudlow/FourXGame/blob/main/docs/plans/4x-game/technical/engine-decoupling/engine-decoupling.md) 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..230cb37 --- /dev/null +++ b/docs/plans/phase-2-headless-adapter-development.md @@ -0,0 +1,622 @@ +# 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 + +Partially 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 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**: 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. + +## Implementation guide + +### Plan requirements + +- (***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 + +***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. 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`. 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`. Records calls (`Play`, `Stop`, `SetVolume`) for test assertion. + +#### 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 by maintaining scripted state (keys held, buttons held, mouse position) +/// for deterministic testing. +/// +public sealed class HeadlessInputProvider : IInputProvider +{ + private readonly HashSet _keysDown = []; + private readonly HashSet _buttonsDown = []; + private (float X, float Y) _mousePosition; + + /// + /// Sets the specified key as held down. + /// + /// The key identifier. + public void ScriptKeyDown(string key) => _keysDown.Add(key); + + /// + /// Releases the specified key. + /// + /// The key identifier. + public void ScriptKeyUp(string key) => _keysDown.Remove(key); + + /// + /// Sets the specified mouse button as held down. + /// + /// The mouse button index. + public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button); + + /// + /// Releases the specified mouse button. + /// + /// 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 StartPlayback(string audioAssetId, bool loopPlayback = false) => + _recordedCalls.Add(("StartPlayback", audioAssetId, loopPlayback)); + + /// + public void StopPlayback(string audioAssetId) => + _recordedCalls.Add(("StopPlayback", 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 + +***Partially complete*** + +#### Objective + +Provide a `TestAdapter` that wraps the HeadlessAdapter and records calls for assertion in CI environments. + +#### Technical details + +- **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 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 (for recorded calls) + - GIVEN expected sequences of recorded 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 + +***Partially complete*** + +#### Objective + +Provide a deterministic execution harness for headless simulations (fixed timestep configuration and seeded RNG) to enable reproducible test runs. + +#### Technical details + +- **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 + +- (***Partially complete***) Fixed timestep execution + - GIVEN a configured fixed timestep interval + - WHEN the simulation runs N steps + - THEN the runner executes N steps (timestep is currently stored, not applied to a simulation clock). + +- (***Partially complete***) Seeded RNG reproducibility + - GIVEN a fixed RNG seed + - WHEN the simulation runs twice with identical inputs + - THEN the runner completes deterministically (no non-deterministic outputs are currently produced by RNG). + +#### 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 + +- 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 + +- 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 + +- **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 + +- [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/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.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.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.Core/EngineConfig.cs b/src/GameEngineAdapter.Core/EngineConfig.cs new file mode 100644 index 0000000..d6c5a86 --- /dev/null +++ b/src/GameEngineAdapter.Core/EngineConfig.cs @@ -0,0 +1,13 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + + +/// +/// 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.Core/FrameScope.cs b/src/GameEngineAdapter.Core/FrameScope.cs new file mode 100644 index 0000000..4ac35d3 --- /dev/null +++ b/src/GameEngineAdapter.Core/FrameScope.cs @@ -0,0 +1,11 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// Disposable marker scope for a single render frame. +/// Disposing this scope currently performs no action. +/// +public readonly record struct FrameScope : IDisposable +{ + /// No-op marker disposal for the current frame scope. + public void Dispose() { /* intentionally no-op marker scope */ } +} 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 d20809e..26007d5 100644 --- a/src/GameEngineAdapter/GameEngineAdapter.csproj +++ b/src/GameEngineAdapter.Core/GameEngineAdapter.Core.csproj @@ -1,10 +1,11 @@ - - - - Exe - net10.0 - enable - enable - - - + + + + net10.0 + enable + enable + + 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..974e5fa --- /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 StartPlayback(string audioAssetId, bool loopPlayback = false); + + /// Stops playback of the specified audio asset. + 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.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..b59ad2a --- /dev/null +++ b/src/GameEngineAdapter.Core/IRenderProvider.cs @@ -0,0 +1,32 @@ +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 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. + 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.Core/MaterialDto.cs b/src/GameEngineAdapter.Core/MaterialDto.cs new file mode 100644 index 0000000..8abf7fa --- /dev/null +++ b/src/GameEngineAdapter.Core/MaterialDto.cs @@ -0,0 +1,12 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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.Core/MeshDrawDto.cs b/src/GameEngineAdapter.Core/MeshDrawDto.cs new file mode 100644 index 0000000..535c82e --- /dev/null +++ b/src/GameEngineAdapter.Core/MeshDrawDto.cs @@ -0,0 +1,14 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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.Core/SpriteDrawDto.cs b/src/GameEngineAdapter.Core/SpriteDrawDto.cs new file mode 100644 index 0000000..992ca44 --- /dev/null +++ b/src/GameEngineAdapter.Core/SpriteDrawDto.cs @@ -0,0 +1,14 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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.Core/TextDrawDto.cs b/src/GameEngineAdapter.Core/TextDrawDto.cs new file mode 100644 index 0000000..d6f4ac6 --- /dev/null +++ b/src/GameEngineAdapter.Core/TextDrawDto.cs @@ -0,0 +1,18 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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.Core/TransformDto.cs b/src/GameEngineAdapter.Core/TransformDto.cs new file mode 100644 index 0000000..728da52 --- /dev/null +++ b/src/GameEngineAdapter.Core/TransformDto.cs @@ -0,0 +1,18 @@ +namespace JohnLudlow.GameEngineAdapter.Core; + +/// +/// 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); 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..65df726 --- /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. +/// Exposes provider instances; individual providers may record their own 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..c95912c --- /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 StartPlayback(string audioAssetId, bool loopPlayback = false) => + _recordedCalls.Add(("Play", audioAssetId, loopPlayback)); + + /// + public void StopPlayback(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..00faf39 --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessInputProvider.cs @@ -0,0 +1,62 @@ +namespace JohnLudlow.GameEngineAdapter.Headless; + +using JohnLudlow.GameEngineAdapter.Core; + +/// +/// Simulates input by maintaining scripted state (keys held, buttons held, mouse position) +/// for deterministic testing. +/// +public sealed class HeadlessInputProvider : IInputProvider +{ + private readonly HashSet _keysDown = []; + private readonly HashSet _buttonsDown = []; + private (float X, float Y) _mousePosition; + + /// + /// Sets the specified key as held down. + /// + /// The key identifier. + public void ScriptKeyDown(string key) => _keysDown.Add(key); + + /// + /// Releases the specified key. + /// + /// The key identifier. + public void ScriptKeyUp(string key) => _keysDown.Remove(key); + + /// + /// Sets the specified mouse button as held down. + /// + /// The mouse button index. + public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button); + + /// + /// Releases the specified mouse button. + /// + /// 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..37d2475 --- /dev/null +++ b/src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs @@ -0,0 +1,48 @@ +using JohnLudlow.GameEngineAdapter.Core; + +namespace JohnLudlow.GameEngineAdapter.Headless; + +/// +/// Records render commands for verification without GPU interaction. +/// +public sealed class HeadlessRenderProvider : IRenderProvider +{ + private readonly List _recordedSprites = []; + private readonly List _recordedTexts = []; + private readonly List _recordedMeshes = []; + + /// 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) => _recordedSprites.Add(dto); + + /// + public void SubmitText(in TextDrawDto dto) => _recordedTexts.Add(dto); + + /// + public void SubmitMesh(in MeshDrawDto dto) => _recordedMeshes.Add(dto); + + /// + public void EndFrame() { } + + /// + public void Present() { } + + /// Clears all recorded commands. + public void Clear() + { + _recordedSprites.Clear(); + _recordedTexts.Clear(); + _recordedMeshes.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..e22c59e --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/DeterministicEngineRunnerTests.cs @@ -0,0 +1,81 @@ +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.RecordedSprites); + Assert.Empty(renderProvider.RecordedTexts); + Assert.Empty(renderProvider.RecordedMeshes); + } + + [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 db296aa..2bc800d 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 @@ -14,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..9770c44 --- /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.StartPlayback("music_main", loopPlayback: 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.StartPlayback("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.StopPlayback("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.StartPlayback("sfx_jump"); + player.SetVolume("sfx_jump", 0.8f); + player.StopPlayback("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.StartPlayback("sfx_a"); + player.StopPlayback("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..cd90d36 --- /dev/null +++ b/src/GameEngineAdapter.UnitTests/HeadlessRenderProviderTests.cs @@ -0,0 +1,142 @@ +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.RecordedSprites); + Assert.Empty(provider.RecordedTexts); + Assert.Empty(provider.RecordedMeshes); + } + + [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.RecordedSprites); + Assert.Equal(dto, provider.RecordedSprites[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.RecordedTexts); + Assert.Equal(dto, provider.RecordedTexts[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.RecordedMeshes); + Assert.Equal(dto, provider.RecordedMeshes[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(2, provider.RecordedSprites.Count); + Assert.Single(provider.RecordedTexts); + Assert.Empty(provider.RecordedMeshes); + } + + [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.RecordedSprites); + Assert.Empty(provider.RecordedTexts); + Assert.Empty(provider.RecordedMeshes); + } + + [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/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); + } +} 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 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