From e2d64a7a5748bd9ee56b217277ece75598a0f9a4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 30 Mar 2026 22:55:17 -0400 Subject: [PATCH] NS-17: PostgreSQL position persistence and integration tests Wire IPositionStateStore to Npgsql when ConnectionStrings:NeonSprawl is set, with versioned DDL under server/db/migrations and a shared PostgresPositionBootstrap for schema plus dev-player seeding at host startup. In-memory tests override Postgres registrations when CI sets the connection string globally. Add GitHub Actions Postgres service for dotnet test, server README updates, decomposition and implementation-plan docs, code review write-up, and C# brace conventions in csharp-style rule. --- .cursor/rules/csharp-style.md | 13 ++ .github/workflows/dotnet.yml | 18 +++ .../modules/E1_M1_InputAndMovementRuntime.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NS-17-implementation-plan.md | 96 ++++++++++++ docs/reviews/2026-03-30-NS-17.md | 71 +++++++++ .../Game/PositionState/MoveCommandApiTests.cs | 9 +- .../PositionState/PositionStateApiTests.cs | 5 +- .../Game/PositionState/PostgresCollection.cs | 8 + .../PostgresPositionStateIntegrationTests.cs | 144 ++++++++++++++++++ .../PostgresWebApplicationFactory.cs | 27 ++++ .../RequirePostgresFactAttribute.cs | 26 ++++ .../InMemoryWebApplicationFactory.cs | 33 ++++ .../NeonSprawl.Server.Tests.csproj | 5 + .../Game/PositionState/IPositionStateStore.cs | 2 +- .../InMemoryPositionStateStore.cs | 17 ++- .../Game/PositionState/PositionStateApi.cs | 12 +- ...ositionStateServiceCollectionExtensions.cs | 19 ++- .../PostgresDevPlayerSeedHostedService.cs | 33 ++++ .../PostgresPositionBootstrap.cs | 84 ++++++++++ .../PostgresPositionStateStore.cs | 106 +++++++++++++ .../NeonSprawl.Server.csproj | 12 ++ server/README.md | 25 ++- .../db/migrations/V001__player_position.sql | 11 ++ 24 files changed, 755 insertions(+), 27 deletions(-) create mode 100644 docs/plans/NS-17-implementation-plan.md create mode 100644 docs/reviews/2026-03-30-NS-17.md create mode 100644 server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/PositionState/RequirePostgresFactAttribute.cs create mode 100644 server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs create mode 100644 server/db/migrations/V001__player_position.sql diff --git a/.cursor/rules/csharp-style.md b/.cursor/rules/csharp-style.md index 92a16c7..531af57 100644 --- a/.cursor/rules/csharp-style.md +++ b/.cursor/rules/csharp-style.md @@ -36,6 +36,19 @@ public sealed class OrderService(IOrderStore store, ILogger log) ## Layout and syntax - **Braces:** opening brace `{` on a **new line** for types and members (Allman-style), per common Microsoft examples. +- **Braces for every block:** never omit `{ }` on `if`, `else`, `for`, `foreach`, `while`, `do`, or `using` when the language allows a single statement without braces—**always** use a braced block, even for one line. This avoids accidental logic changes when editing. Expression-bodied members and expression lambdas (`x => x.Id`) stay valid when the whole body is a single expression. + +```csharp +// Prefer +if (string.IsNullOrEmpty(key)) +{ + return false; +} + +// Avoid +if (string.IsNullOrEmpty(key)) + return false; +``` - **Indentation:** **4 spaces** per level; no tabs unless the file already uses tabs—never mix. - **`var`:** use when the type is obvious from the right-hand side; use explicit types when it improves readability or for literals where the type matters. - **File-scoped namespaces** (`namespace X;`) for new single-namespace files when the SDK/version allows. diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 885d13b..5a75377 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,6 +18,21 @@ on: jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: neon_sprawl + POSTGRES_PASSWORD: neon_sprawl_dev + POSTGRES_DB: neon_sprawl + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U neon_sprawl -d neon_sprawl" + --health-interval=5s + --health-timeout=5s + --health-retries=5 + steps: - uses: actions/checkout@v4 @@ -33,4 +48,7 @@ jobs: run: dotnet build NeonSprawl.sln --no-restore --configuration Release - name: Test + env: + ConnectionStrings__NeonSprawl: >- + Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev run: dotnet test NeonSprawl.sln --no-build --configuration Release --verbosity normal diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index a1757d2..1a59d22 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -33,8 +33,8 @@ Contract readiness is tracked in the [module dependency register](module_depende ## Implementation snapshot -- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); in-memory store; Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). -- **Not yet:** Persistence, prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. +- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). +- **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). ## Module dependencies diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index a6aec40..077a222 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -45,7 +45,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | Module | Register status | Snapshot | Plans / pointers | |--------|-----------------|----------|-------------------| -| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); in-memory store; Godot client sync (NS-16). Persistence / prediction / full slice still open. | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`; [server README — Position / move](../../../server/README.md) | +| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client sync (NS-16). Prediction / full slice still open. | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), [NS-17](../../plans/NS-17-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`; [server README — Position / persistence](../../../server/README.md) | --- diff --git a/docs/plans/NS-17-implementation-plan.md b/docs/plans/NS-17-implementation-plan.md new file mode 100644 index 0000000..e99afa8 --- /dev/null +++ b/docs/plans/NS-17-implementation-plan.md @@ -0,0 +1,96 @@ +# NS-17 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NS-17 | +| **Title** | E1.M1: Persist character position (PostgreSQL) | +| **Jira** | [NS-17](https://neon-sprawl.atlassian.net/browse/NS-17) | +| **Parent context** | [NS-10 — E1.M1 InputAndMovementRuntime](https://neon-sprawl.atlassian.net/browse/NS-10) · [NS-16](NS-16-implementation-plan.md) (MoveCommand → `PositionState`) | + +## Goal, scope, and out-of-scope + +**Goal:** **`PositionState` survives server restart** via a thin PostgreSQL persistence path wired into the existing GET/POST endpoints. + +**In scope** + +- SQL migration or script for a minimal table: player identifier, position fields, monotonic **`sequence`**, **`updated_at`** (aligned with [`PositionSnapshot`](../../server/NeonSprawl.Server/Game/PositionState/PositionSnapshot.cs) and HTTP contract). +- Repository / store implementation used by **`IPositionStateStore`** so [`PositionStateApi`](../../server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs) stays unchanged. +- Reuse **[`docker-compose.yml`](../../docker-compose.yml)** Postgres from repo root / [root README](../../README.md). +- Document how the server picks the database (configuration / env vars). + +**Out of scope (per Jira)** + +- Full character creation flow, multiple zones, ORM/migration framework polish beyond what is needed for this slice. + +**Persistence contract (columns vs JSONB)** + +- Use **relational columns** (`pos_x`, `pos_y`, `pos_z`, `sequence`, `updated_at`) for the row — simple queries, clear mapping to `PositionSnapshot`, no JSON parsing. Note this choice in `server/README.md` when describing persistence. + +## Acceptance criteria checklist + +- [ ] Restart **`dotnet run`** with the same Postgres volume: last committed position is still returned from **`GET /game/players/{id}/position`** (after a prior **`POST …/move`**). +- [ ] Connection / database configuration documented (env vars or `ConnectionStrings`, aligned with ASP.NET conventions). +- [ ] No regression to the **in-memory** happy path: default **`dotnet test`** / CI without Postgres continues to behave as NS-15/NS-16 (see [Tests](#tests)). + +## Technical approach + +1. **Configuration** + - Introduce a **connection string** (e.g. `ConnectionStrings:NeonSprawl` or `Default`) in `appsettings.Development.json` / environment overrides. Mirror docker-compose defaults already listed in [root `README.md`](../../README.md) (`localhost:5432`, `neon_sprawl`, `neon_sprawl` / `neon_sprawl_dev`). + - **`AddPositionStateStore`**: when the connection string is **present and non-empty**, register a **PostgreSQL-backed** `IPositionStateStore`; otherwise keep **`InMemoryPositionStateStore`** (preserves CI and local tests without DB). + +2. **Schema** + - Add a versioned SQL file under the server tree (e.g. `server/db/migrations/V001__player_position.sql`) with `CREATE TABLE IF NOT EXISTS …` and indexes needed for lookups. + - Table name suggestion: **`player_position`** (avoids implying a full **`characters`** model). Columns: `player_id` (text, primary key or unique), `pos_x`, `pos_y`, `pos_z` (`double precision`), `sequence` (`bigint`), `updated_at` (`timestamptz`). Case-insensitive id matching should match [`InMemoryPositionStateStore`](../../server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs) (trim + ordinal case-insensitive semantics): implement with **`LOWER(TRIM(player_id))`** uniqueness / lookup pattern, or document that stored ids are normalized once — **pick one approach and apply consistently in SQL + code**. + +3. **Store implementation** + - New class (e.g. **`PostgresPositionStateStore`**) implementing **`IPositionStateStore`**, using **Npgsql** (`NpgsqlDataSource` or `NpgsqlConnection` via DI) — add package to [`NeonSprawl.Server.csproj`](../../server/NeonSprawl.Server/NeonSprawl.Server.csproj). + - **Seed dev player:** on construction (or first DB touch), ensure the row for **`Game:DevPlayerId`** exists with **`Game:DefaultPosition`** and **`sequence = 0`**, analogous to in-memory startup seeding. + - **`TryGetPosition`:** read from DB; return **`false`** if no row (unknown player). + - **`TryApplyMoveTarget`:** single transactional **`UPDATE`** that sets position, increments **`sequence`**, bumps **`updated_at`**, and returns the new row (or use a concurrency-safe pattern). Unknown player → **`false`**. Preserve “404 for unknown id” behavior. + +4. **Startup / DDL** + - Either run idempotent DDL once at startup (lightweight for this story) **or** require `psql -f` / compose init — **prefer idempotent startup ensure** so `docker compose up` + `dotnet run` works without a manual step; keep the SQL file in repo as the source of truth. + +5. **Documentation** + - Update **[`server/README.md`](../../server/README.md):** persistence subsection (Postgres vs in-memory toggle), link to SQL file, note restart verification steps, document **env vars** (e.g. `ConnectionStrings__NeonSprawl` for Docker / cloud) in addition to the literals already on the root README. + +## Files to add + +| Path | Purpose | +|------|--------| +| `server/db/migrations/V001__player_position.sql` (or similar) | Idempotent DDL for `player_position` (+ comments). | +| `server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs` (name may vary) | Npgsql-backed `IPositionStateStore` + seed + read/update. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs` (name may vary) | xUnit integration tests against **real PostgreSQL** (HTTP + persistence boundary; see [Tests](#tests)). | +| Optional: `.../PositionState/DatabasePositionOptions.cs` | Section binding for connection string name / feature flag if needed. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| [`server/NeonSprawl.Server/NeonSprawl.Server.csproj`](../../server/NeonSprawl.Server/NeonSprawl.Server.csproj) | Add **Npgsql** (and any needed packages). | +| [`server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs`](../../server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs) | Register Postgres vs in-memory from configuration. | +| [`server/NeonSprawl.Server/appsettings.json`](../../server/NeonSprawl.Server/appsettings.json) / `appsettings.Development.json` | Optional default connection string for local dev (or leave empty to default in-memory). | +| [`server/README.md`](../../server/README.md) | Postgres persistence, env vars, manual restart acceptance check, how to run **Postgres integration tests** locally. | +| [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml) | Add a **PostgreSQL service container** (same image/credentials as [`docker-compose.yml`](../../docker-compose.yml)) and pass a **connection string** into the **Test** step so Postgres integration tests run on every push/PR that touches the server. | + +## Tests + +- **Existing** [`MoveCommandApiTests`](../../server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs) and [`PositionStateApiTests`](../../server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs): unchanged setup — **no** connection string so the host keeps **`InMemoryPositionStateStore`** — satisfies “no regression to in-memory happy path.” +- **New (required):** **PostgreSQL integration tests** per [`.cursor/rules/testing-expectations.md`](../../.cursor/rules/testing-expectations.md): use **`WebApplicationFactory`** with configuration (or environment) that sets the same **connection string** the app uses for the durable store, pointing at real Postgres. + - Cover at least: **POST move** then **GET** sees stored position and **`sequence`**; **persistence across host restarts** simulated by **disposing** the first factory and creating a **second** `WebApplicationFactory` with the same connection string, then **GET** still returns the last committed state (matches Jira “restart server + DB” without a literal process kill). + - Optionally add a **failure-path** case (e.g. unknown player **404** on move when row absent) if the implementation keeps that behavior for Postgres. +- **CI:** [.github/workflows/dotnet.yml](../../.github/workflows/dotnet.yml) must run these tests **always** in the existing job (service container + env), not as an optional manual job — so the durable path is gated like the rest of the suite. +- **Local:** document in **`server/README.md`**: run **`docker compose up -d`** (or any Postgres with matching credentials) and set the same connection string env vars the workflow uses, then `dotnet test`. + +**Manual verification (Jira AC, optional double-check):** `docker compose up -d` → `dotnet run` with Postgres enabled → `POST …/move` → stop server → `dotnet run` again → `GET …/position` matches last move. + +## Open questions / risks + +- **Case sensitivity:** DB must match current **trim + case-insensitive** player id semantics; enforce via normalized column or `LOWER` constraints to avoid subtle 404 vs memory mismatches. +- **Service container vs Testcontainers:** Prefer **GitHub Actions `services: postgres`** for CI (no new test dependency). If flakiness appears, consider **Testcontainers** later; initial implementation should not defer integration tests. + +## Pull request description (draft) + +**NS-17:** Persist `PositionState` in PostgreSQL when a connection string is configured; keep in-memory store when not. Adds DDL + Npgsql-backed `IPositionStateStore`, **PostgreSQL integration tests** (including simulating restart via a second web factory), **Postgres service in `dotnet.yml`**, and README notes for env vars and local test runs. diff --git a/docs/reviews/2026-03-30-NS-17.md b/docs/reviews/2026-03-30-NS-17.md new file mode 100644 index 0000000..6985d64 --- /dev/null +++ b/docs/reviews/2026-03-30-NS-17.md @@ -0,0 +1,71 @@ +# Code review — NS-17 (PostgreSQL position persistence) + +**Date:** 2026-03-30 + +**Scope:** Branch `NS-17-persist-position-postgres`; Jira [NS-17](https://neon-sprawl.atlassian.net/browse/NS-17). Working tree includes **modified** and **untracked** files (e.g. `PostgresPositionStateStore.cs`, `server/db/`, Postgres test types are **untracked** at review time). + +**Base:** `main` (comparison: `git diff main --stat` plus untracked NS-17 assets). + +--- + +## Verdict + +**Approve with nits** — Persistence wiring matches the NS-17 plan and authority docs; tests and CI cover the durable path. Decomposition **implementation snapshot** docs updated for NS-17 (see **Author follow-up** below). + +--- + +## Summary + +This work adds a PostgreSQL-backed `IPositionStateStore`, a versioned DDL file under `server/db/migrations/`, configuration via `ConnectionStrings:NeonSprawl`, idempotent schema application once per process, and per-request idempotent dev-row seeding so behavior stays consistent with TRUNCATE-heavy tests. GitHub Actions gains a Postgres service and passes the connection string into `dotnet test`. In-memory API tests are isolated via `InMemoryWebApplicationFactory` + `ConfigureTestServices` so a global CI env var does not poison the in-memory suite. Overall risk is **low–medium**: prototype-grade lifecycle (no explicit `NpgsqlDataSource` disposal), runtime DDL from the app, and every request paying a seed `INSERT` (no-op when row exists). + +The diff also includes **repo-wide C# brace style** updates in `.cursor/rules/csharp-style.md` and touched server files — acceptable but **mixed scope** vs NS-17; consider separate commit or callout in PR. + +--- + +## Documentation checked + +| Document | Assessment | +|----------|------------| +| `docs/plans/NS-17-implementation-plan.md` | **Matches** — columns not JSONB, `player_position`, `IPositionStateStore`, connection string toggle, CI service + env, integration tests (POST→GET, second factory, unknown player), README/env docs. **Partially matches** — plan listed optional `appsettings.Development.json`; implementation correctly leaves connection string unset by default (still within plan). | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server remains authoritative for `PositionState`; persistence does not move truth to the client. | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches (post-review update)** — Snapshot describes optional Postgres persistence + NS-17; “Not yet” trimmed to prediction / full slice / telemetry. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches (post-review update)** — E1.M1 tracking row includes NS-17 and Postgres-when-configured. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M1 remains **In Progress**; no contract version promotion required for this slice. | +| `docs/decomposition/modules/contracts.md` | **Matches** — HTTP JSON shapes unchanged; NS-17 is durability, not a breaking wire change. | +| `server/README.md` | **Matches** — persistence, env vars, local Postgres test instructions. | + +--- + +## Blocking issues + +*None identified.* + +--- + +## Suggestions + +1. ~~**Docs alignment (recommended before merge):**~~ **Done** — `E1_M1_InputAndMovementRuntime.md` and `documentation_and_implementation_alignment.md` updated for NS-17 / optional Postgres (planned follow-up; separate from **Nit 1** below). +2. ~~**Lifecycle:**~~ **Tracked:** [NS-22](https://neon-sprawl.atlassian.net/browse/NS-22) under epic [NS-20 — Tech Debt](https://neon-sprawl.atlassian.net/browse/NS-20) — dispose `NpgsqlDataSource` on host shutdown (was: singleton never disposed; acceptable for prototype until addressed). +3. ~~**Naming clarity:**~~ **Done** — `EnsureSchema()` XML summary in `PostgresPositionStateStore.cs` now states the heavy path (read migration + DDL) runs at most once per process; later calls are the fast volatile-read path. No rename. + +--- + +## Nits + +- ~~**`.vscode/`** appears as untracked in the working tree~~ — **Resolved (author):** no longer listed as untracked; do not commit editor-local `.vscode` unless the team opts in. +- ~~**`EnsureDevPlayerRow` every call**~~ **Addressed:** dev row is seeded at PostgreSQL host startup (`PostgresDevPlayerSeedHostedService`); integration tests re-seed after `TRUNCATE` via `PostgresPositionBootstrap.SeedDevPlayer` + `IOptions` from the factory. +- ~~**Unicode normalization:**~~ **Deferred / accepted:** team expects **ASCII** player ids; `ToLowerInvariant()` vs in-memory `StringComparer.OrdinalIgnoreCase` parity for full Unicode is **not** a priority unless requirements change. + +--- + +## Verification + +Before merge, the author should run: + +1. **No Postgres:** `dotnet test NeonSprawl.sln -c Release` — expect in-memory tests green; Postgres tests **skipped** if `ConnectionStrings__NeonSprawl` is unset. +2. **With Postgres:** `docker compose up -d` from repo root, then + `export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev'` + and `dotnet test NeonSprawl.sln -c Release` — all tests including `PostgresPositionStateIntegrationTests` should pass. +3. **Manual AC (optional):** `dotnet run` with connection string set, `POST …/move`, restart server, `GET …/position` unchanged. + +Ensure **all** NS-17 files (including `server/db/` and new test types) are **staged and committed** — at review time much of the implementation was still untracked relative to `git status`. diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs index 58e3f51..4c772c4 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Json; using System.Text; -using Microsoft.AspNetCore.Mvc.Testing; using NeonSprawl.Server.Game.PositionState; using Xunit; @@ -17,7 +16,7 @@ public class MoveCommandApiTests public async Task PostMove_ShouldPersistTargetAndIncrementSequence_WhenDevPlayerPostsValidMove() { // Arrange - using var factory = new WebApplicationFactory(); + await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var cmd = new MoveCommandRequest { @@ -55,7 +54,7 @@ public class MoveCommandApiTests public async Task PostMove_ShouldReturnNotFound_WhenPlayerIsUnknown() { // Arrange - using var factory = new WebApplicationFactory(); + await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var cmd = new MoveCommandRequest { @@ -74,7 +73,7 @@ public class MoveCommandApiTests public async Task PostMove_ShouldReturnBadRequest_WhenSchemaVersionIsWrong() { // Arrange - using var factory = new WebApplicationFactory(); + await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var cmd = new MoveCommandRequest { @@ -93,7 +92,7 @@ public class MoveCommandApiTests public async Task PostMove_ShouldReturnBadRequest_WhenTargetIsMissing() { // Arrange - using var factory = new WebApplicationFactory(); + await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var content = new StringContent( "{\"schemaVersion\":1}", diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs index d46e9bf..2adc1ae 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs @@ -1,14 +1,13 @@ using System.Net; using System.Net.Http.Json; -using Microsoft.AspNetCore.Mvc.Testing; using NeonSprawl.Server.Game.PositionState; using Xunit; namespace NeonSprawl.Server.Tests.Game.PositionState; /// Integration tests for GET routes. -public class PositionStateApiTests(WebApplicationFactory factory) - : IClassFixture> +public class PositionStateApiTests(InMemoryWebApplicationFactory factory) + : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs new file mode 100644 index 0000000..8d5a59b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +[CollectionDefinition("Postgres integration")] +public sealed class PostgresCollection : ICollectionFixture +{ +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs new file mode 100644 index 0000000..e7bf450 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +/// NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database. +[Collection("Postgres integration")] +public sealed class PostgresPositionStateIntegrationTests +{ + private readonly PostgresWebApplicationFactory _factory; + + public PostgresPositionStateIntegrationTests(PostgresWebApplicationFactory factory) + { + _factory = factory; + } + + [RequirePostgresFact] + public async Task PostMove_ThenGet_ShouldReflectPersistedPositionAndSequence() + { + // Arrange + await ResetPlayerPositionTableAsync(); + using var client = _factory.CreateClient(); + var cmd = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 2, Y = -1, Z = 3.5 }, + }; + + // Act + var before = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); + var post = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd); + var postBody = await post.Content.ReadFromJsonAsync(); + var after = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); + + // Assert + Assert.NotNull(before); + Assert.Equal(HttpStatusCode.OK, post.StatusCode); + Assert.NotNull(postBody); + Assert.NotNull(after); + Assert.Equal(PositionStateResponse.CurrentSchemaVersion, postBody!.SchemaVersion); + Assert.Equal(before!.Sequence + 1, postBody.Sequence); + Assert.Equal(2, postBody.Position.X); + Assert.Equal(-1, postBody.Position.Y); + Assert.Equal(3.5, postBody.Position.Z); + Assert.Equal(postBody.Sequence, after!.Sequence); + Assert.Equal(postBody.Position.X, after.Position.X); + Assert.Equal(postBody.Position.Y, after.Position.Y); + Assert.Equal(postBody.Position.Z, after.Position.Z); + } + + [RequirePostgresFact] + public async Task SecondProcess_ShouldReadLastPosition_AfterFirstFactoryDisposed() + { + // Arrange + await ResetPlayerPositionTableAsync(); + var cmd = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 10, Y = 20, Z = 30 }, + }; + + HttpStatusCode postStatus = default; + PositionStateResponse? persisted = null; + + // Act + using (var first = _factory.CreateClient()) + { + var post = await first.PostAsJsonAsync("/game/players/dev-local-1/move", cmd); + postStatus = post.StatusCode; + persisted = await post.Content.ReadFromJsonAsync(); + } + + await using var secondFactory = new PostgresWebApplicationFactory(); + using var secondClient = secondFactory.CreateClient(); + var after = await secondClient.GetFromJsonAsync("/game/players/dev-local-1/position"); + + // Assert + Assert.Equal(HttpStatusCode.OK, postStatus); + Assert.NotNull(persisted); + Assert.NotNull(after); + Assert.Equal(10, after!.Position.X); + Assert.Equal(20, after.Position.Y); + Assert.Equal(30, after.Position.Z); + Assert.Equal(1ul, after.Sequence); + } + + [RequirePostgresFact] + public async Task PostMove_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await ResetPlayerPositionTableAsync(); + using var client = _factory.CreateClient(); + var cmd = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 0, Y = 0, Z = 0 }, + }; + + // Act + var response = await client.PostAsJsonAsync("/game/players/not-a-seeded-player/move", cmd); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// DDL + empty table + dev row matching the test host's (parity with startup seed). + private async Task ResetPlayerPositionTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = _factory.Services; + var options = _factory.Services.GetRequiredService>().Value; + + var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = await File.ReadAllTextAsync(ddlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using (var apply = new NpgsqlCommand(ddl, conn)) + { + await apply.ExecuteNonQueryAsync(); + } + + await using (var truncate = new NpgsqlCommand("TRUNCATE player_position;", conn)) + { + await truncate.ExecuteNonQueryAsync(); + } + + PostgresPositionBootstrap.SeedDevPlayer(conn, options); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs new file mode 100644 index 0000000..73ea3d0 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +/// Integration host with bound from environment. +public sealed class PostgresWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException( + "Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md)."); + } + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:NeonSprawl"] = cs.Trim(), + }); + }); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/RequirePostgresFactAttribute.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/RequirePostgresFactAttribute.cs new file mode 100644 index 0000000..2f794b1 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/RequirePostgresFactAttribute.cs @@ -0,0 +1,26 @@ +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +/// Skips unless ConnectionStrings__NeonSprawl is set; fails in CI if missing so misconfigured workflows do not silently pass. +public sealed class RequirePostgresFactAttribute : FactAttribute +{ + public RequirePostgresFactAttribute() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + var inCi = string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.Ordinal); + + if (!string.IsNullOrWhiteSpace(cs)) + { + return; + } + + if (inCi) + { + throw new InvalidOperationException( + "CI must set ConnectionStrings__NeonSprawl for Postgres integration tests."); + } + + Skip = "Set ConnectionStrings__NeonSprawl to run Postgres integration tests (see server/README.md)."; + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs new file mode 100644 index 0000000..b4f4ccc --- /dev/null +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NeonSprawl.Server.Game.PositionState; +using Npgsql; + +namespace NeonSprawl.Server.Tests; + +/// Forces the in-memory position store by replacing Postgres registrations after the host builds services (overrides process env e.g. CI ConnectionStrings__NeonSprawl). +public sealed class InMemoryWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + for (var i = services.Count - 1; i >= 0; i--) + { + var d = services[i]; + if (d.ServiceType == typeof(IPositionStateStore) || + d.ServiceType == typeof(NpgsqlDataSource) || + (d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService))) + { + services.RemoveAt(i); + } + } + + services.AddSingleton(); + }); + } +} diff --git a/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj b/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj index 2c0dea6..1e1ec1c 100644 --- a/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +++ b/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj @@ -9,6 +9,7 @@ + @@ -22,4 +23,8 @@ + + + + diff --git a/server/NeonSprawl.Server/Game/PositionState/IPositionStateStore.cs b/server/NeonSprawl.Server/Game/PositionState/IPositionStateStore.cs index 91ecf58..203b29e 100644 --- a/server/NeonSprawl.Server/Game/PositionState/IPositionStateStore.cs +++ b/server/NeonSprawl.Server/Game/PositionState/IPositionStateStore.cs @@ -1,6 +1,6 @@ namespace NeonSprawl.Server.Game.PositionState; -/// In-memory source of truth for player positions (NS-15 read API, NS-16 move apply). +/// Authoritative player positions — in-memory or PostgreSQL per configuration (NS-15 read API, NS-16 move apply, NS-17 persistence). public interface IPositionStateStore { /// Returns false if the player is unknown (HTTP 404). is trimmed; matching is ordinal case-insensitive. diff --git a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs index 9bf93d0..ed436e6 100644 --- a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs +++ b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs @@ -13,7 +13,9 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore var o = options.Value; var id = o.DevPlayerId.Trim(); if (id.Length == 0) + { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); + } var p = o.DefaultPosition ?? new DefaultPositionOptions(); _positions[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); @@ -22,13 +24,13 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore public bool TryGetPosition(string playerId, out PositionSnapshot snapshot) { var key = playerId?.Trim(); - if (string.IsNullOrEmpty(key)) + if (!string.IsNullOrEmpty(key)) { - snapshot = default; - return false; + return _positions.TryGetValue(key, out snapshot); } + snapshot = default; + return false; - return _positions.TryGetValue(key, out snapshot); } public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot) @@ -49,11 +51,12 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore } var next = new PositionSnapshot(x, y, z, previous.Sequence + 1); - if (_positions.TryUpdate(key, next, previous)) + if (!_positions.TryUpdate(key, next, previous)) { - snapshot = next; - return true; + continue; } + snapshot = next; + return true; } } } diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs index 2e2a41a..7a3c72e 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs @@ -10,7 +10,9 @@ public static class PositionStateApi (string id, IPositionStateStore store) => { if (!store.TryGetPosition(id, out var snap)) + { return Results.NotFound(); + } var body = new PositionStateResponse { @@ -26,17 +28,21 @@ public static class PositionStateApi "/game/players/{id}/move", (string id, MoveCommandRequest? body, IPositionStateStore store) => { - if (body is null) - return Results.BadRequest(); - if (body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) + if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) + { return Results.BadRequest(); + } var target = body.Target; if (target is null) + { return Results.BadRequest(); + } if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap)) + { return Results.NotFound(); + } var response = new PositionStateResponse { diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs index 92fe0df..6d309d4 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs @@ -1,12 +1,27 @@ namespace NeonSprawl.Server.Game.PositionState; -/// Registers in-memory position authority and options for NS-15. +/// Registers position authority: PostgreSQL when ConnectionStrings:NeonSprawl is set, otherwise in-memory (NS-15 / NS-17). public static class PositionStateServiceCollectionExtensions { + private const string NeonSprawlConnectionStringName = "NeonSprawl"; + public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(GamePositionOptions.SectionName)); - services.AddSingleton(); + + var cs = configuration.GetConnectionString(NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + var trimmed = cs.Trim(); + services.AddSingleton(_ => Npgsql.NpgsqlDataSource.Create(trimmed)); + services.AddHostedService(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + return services; } } diff --git a/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs b/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs new file mode 100644 index 0000000..55217b2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Options; + +namespace NeonSprawl.Server.Game.PositionState; + +/// Runs DDL and dev-player seed once when the host starts (PostgreSQL path only). +internal sealed class PostgresDevPlayerSeedHostedService : IHostedService +{ + private readonly Npgsql.NpgsqlDataSource _dataSource; + private readonly GamePositionOptions _options; + + public PostgresDevPlayerSeedHostedService( + Npgsql.NpgsqlDataSource dataSource, + IOptions options) + { + _dataSource = dataSource; + var o = options.Value; + if (string.IsNullOrWhiteSpace(o.DevPlayerId)) + { + throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); + } + + _options = o; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + PostgresPositionBootstrap.EnsureSchema(_dataSource); + PostgresPositionBootstrap.SeedDevPlayer(_dataSource, _options); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs new file mode 100644 index 0000000..76796b1 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs @@ -0,0 +1,84 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// +/// Applies NS-17 DDL (once per process, idempotent) and seeds the configured dev player row. +/// Used at host startup and by integration tests after TRUNCATE. +/// +public static class PostgresPositionBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V001__player_position.sql"); + + private static readonly object SchemaGate = new(); + private static int _schemaReady; + + /// + /// Ensures player_position exists. Heavy work (read migration, run DDL) runs at most once per process. + /// + public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + lock (SchemaGate) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"NS-17 DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using (var cmd = new Npgsql.NpgsqlCommand(ddl, conn)) + { + cmd.ExecuteNonQuery(); + } + + System.Threading.Volatile.Write(ref _schemaReady, 1); + } + } + + /// Idempotent insert for at default position. + public static void SeedDevPlayer(Npgsql.NpgsqlDataSource dataSource, GamePositionOptions options) + { + using var conn = dataSource.OpenConnection(); + SeedDevPlayer(conn, options); + } + + /// Idempotent insert using an existing open connection (e.g. test harness after TRUNCATE). + public static void SeedDevPlayer(Npgsql.NpgsqlConnection connection, GamePositionOptions options) + { + var devNorm = NormalizePlayerId(options.DevPlayerId); + var p = options.DefaultPosition ?? new DefaultPositionOptions(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence) + VALUES (@pid, @x, @y, @z, 0) + ON CONFLICT (player_id) DO NOTHING; + """, + connection); + cmd.Parameters.AddWithValue("pid", devNorm); + cmd.Parameters.AddWithValue("x", p.X); + cmd.Parameters.AddWithValue("y", p.Y); + cmd.Parameters.AddWithValue("z", p.Z); + cmd.ExecuteNonQuery(); + } + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); + } + + return t.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs new file mode 100644 index 0000000..f48df4e --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Options; + +namespace NeonSprawl.Server.Game.PositionState; + +/// PostgreSQL-backed (NS-17). Player ids are stored normalized: invariant lower + trim for case-insensitive parity with . +public sealed class PostgresPositionStateStore : IPositionStateStore +{ + private readonly Npgsql.NpgsqlDataSource _dataSource; + private readonly GamePositionOptions _options; + + public PostgresPositionStateStore(Npgsql.NpgsqlDataSource dataSource, IOptions options) + { + _dataSource = dataSource; + _options = options.Value; + var id = _options.DevPlayerId.Trim(); + if (id.Length == 0) + { + throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); + } + } + + public bool TryGetPosition(string playerId, out PositionSnapshot snapshot) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + snapshot = default; + return false; + } + + PostgresPositionBootstrap.EnsureSchema(_dataSource); + + using var conn = _dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + "SELECT pos_x, pos_y, pos_z, sequence FROM player_position WHERE player_id = @pid;", + conn); + cmd.Parameters.AddWithValue("pid", norm); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + snapshot = default; + return false; + } + + snapshot = ReadSnapshot(reader); + return true; + } + + public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + snapshot = default; + return false; + } + + PostgresPositionBootstrap.EnsureSchema(_dataSource); + + using var conn = _dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + UPDATE player_position + SET pos_x = @x, pos_y = @y, pos_z = @z, + sequence = sequence + 1, + updated_at = now() + WHERE player_id = @pid + RETURNING pos_x, pos_y, pos_z, sequence; + """, + conn); + cmd.Parameters.AddWithValue("pid", norm); + cmd.Parameters.AddWithValue("x", x); + cmd.Parameters.AddWithValue("y", y); + cmd.Parameters.AddWithValue("z", z); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + snapshot = default; + return false; + } + + snapshot = ReadSnapshot(reader); + return true; + } + + private static PositionSnapshot ReadSnapshot(Npgsql.NpgsqlDataReader reader) + { + var seq = reader.GetInt64(3); + return new PositionSnapshot( + reader.GetDouble(0), + reader.GetDouble(1), + reader.GetDouble(2), + unchecked((ulong)seq)); + } + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/NeonSprawl.Server.csproj b/server/NeonSprawl.Server/NeonSprawl.Server.csproj index 7110b71..73ca39d 100644 --- a/server/NeonSprawl.Server/NeonSprawl.Server.csproj +++ b/server/NeonSprawl.Server/NeonSprawl.Server.csproj @@ -14,4 +14,16 @@ false + + + + + + + + + + + + diff --git a/server/README.md b/server/README.md index 677cf4b..1391943 100644 --- a/server/README.md +++ b/server/README.md @@ -1,6 +1,6 @@ # Game server (`NeonSprawl.Server`) -ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + in-memory position read API). +ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + position state via in-memory **or** PostgreSQL). ## Prerequisites @@ -16,9 +16,28 @@ dotnet run - Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`). - Check `GET /health` for a JSON heartbeat. +## Position persistence (NS-17) + +When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request). + +If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NS-15/NS-16 (**in-memory** only). This keeps `dotnet test` without Postgres viable for fast checks; **override any process-level env** in tests when you need in-memory (see `InMemoryWebApplicationFactory` in the test project). + +**Environment variables** (typical — same mapping as other ASP.NET Core config): + +| Mechanism | Example | +|-----------|---------| +| Shell / CI | `export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev'` | +| User secrets | `dotnet user-secrets set ConnectionStrings:NeonSprawl ""` (run from `server/NeonSprawl.Server`) | + +Match credentials with [root `docker-compose.yml`](../../docker-compose.yml) for local development. + +**Restart check (Jira AC):** start Postgres (`docker compose up -d` from repo root), set the connection string, `dotnet run`, `POST …/move`, stop the server, `dotnet run` again, `GET …/position` should return the last committed state. + +**Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml)). Locally: export the same value while Postgres is listening, then `dotnet test NeonSprawl.sln`. + ## Position state (NS-15) -Authoritative player position is held **in memory** (no database yet). The HTTP JSON shape is versioned with top-level `schemaVersion` (see XML docs on `PositionStateResponse` in the server project). Path `{id}` is **trimmed** and matched **case-insensitively** against stored players; `playerId` in the JSON body echoes the path segment from the request. +Authoritative player position is served over HTTP whether the backing store is **in memory** or **PostgreSQL** (NS-17). The HTTP JSON shape is versioned with top-level `schemaVersion` (see XML docs on `PositionStateResponse` in the server project). Path `{id}` is **trimmed** and matched **case-insensitively** against stored players; `playerId` in the JSON body echoes the path segment from the request. Stored player ids use **invariant lowercase + trim** in Postgres for lookup parity. Example (dev player id defaults to `dev-local-1` in `appsettings.json`): @@ -32,7 +51,7 @@ Sample response: {"schemaVersion":1,"playerId":"dev-local-1","position":{"x":0,"y":0,"z":0},"sequence":0} ``` -Unknown player ids return **404**. This endpoint is a **spike** until durable persistence and full sync exist. +Unknown player ids return **404**. Full zone sync / replication is still out of scope for these spikes. ## Move command (NS-16) diff --git a/server/db/migrations/V001__player_position.sql b/server/db/migrations/V001__player_position.sql new file mode 100644 index 0000000..509acaf --- /dev/null +++ b/server/db/migrations/V001__player_position.sql @@ -0,0 +1,11 @@ +-- NS-17: authoritative player position (column layout; player_id is normalized in app: lower(trim(raw))). +CREATE TABLE IF NOT EXISTS player_position ( + player_id TEXT PRIMARY KEY, + pos_x DOUBLE PRECISION NOT NULL, + pos_y DOUBLE PRECISION NOT NULL, + pos_z DOUBLE PRECISION NOT NULL, + sequence BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE player_position IS 'Persisted PositionSnapshot per player (NS-17).';