Merge pull request #15 from ViPro-Technologies/NS-17-persist-position-postgres
NS-17: PostgreSQL position persistence and integration testspull/16/head
commit
2b0a050645
|
|
@ -16,6 +16,11 @@ alwaysApply: true
|
|||
- The user should **review** the diff before anything is committed. The agent’s job is to make the changes visible (uncommitted) and explain them; the user decides when to commit.
|
||||
- If the user asks to commit, use a message that matches repo conventions; still follow [git workflow](git-workflow.md) (branch vs `main`, doc-only vs code).
|
||||
|
||||
## Commit message format when a Jira story applies
|
||||
|
||||
- Any commit that is **part of implementing or delivering a Jira story or task** must put the **Jira issue key first** in the subject line, then **`:`**, then the summary (e.g. `NS-17: persist position state in PostgreSQL`).
|
||||
- Infer the key from the active branch name, the issue under discussion, or Jira context. Full rules (multi-issue commits, `chore:` when there is no ticket) are in [jira-git-naming](jira-git-naming.md).
|
||||
|
||||
## Pull request and push descriptions
|
||||
|
||||
- Do **not** add **“Made-with: Cursor”**, **“Generated with Cursor”**, tool co-author lines, or similar AI/IDE boilerplate to **PR descriptions**, **GitHub merge/squash commit bodies** you draft, or other **remote-facing** narrative unless the user explicitly requests it.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,19 @@ public sealed class OrderService(IOrderStore store, ILogger<OrderService> 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.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ When suggesting or creating a branch for story **NS-14**, use something like **`
|
|||
|
||||
## Commit messages
|
||||
|
||||
- **First token** of the subject line must be the **Jira key and number**, then **`:`** and the summary.
|
||||
- For **any commit done as part of a Jira story, bug, or task** (work tracked under an issue), the **first token** of the subject line must be the **Jira key and number**, then **`:`** and the summary.
|
||||
- **Good:** `NS-14: add direct click-to-move steering`, `NS-20: fix duplicate spawn on reconnect`
|
||||
- **Avoid:** `fix(client): …` or `feat: …` **without** the Jira key at the front. If you use Conventional Commits, place them **after** the key: `NS-14: feat(client): click-to-move prototype`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,3 +7,5 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
|
|||
| **Code review** | [`.cursor/rules/code-review-agent.md`](.cursor/rules/code-review-agent.md) | PR / diff / pre-merge review; **always writes** `docs/reviews/YYYY-MM-DD-{slug}.md` with **Documentation checked** vs `docs/plans/` and `docs/decomposition/modules/`; short chat pointer |
|
||||
|
||||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **PR / push text:** no “Made-with: Cursor” boilerplate — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md).
|
||||
|
||||
**Commits tied to a Jira issue** must start the subject with the **issue key** and a colon (e.g. `NS-17: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/jira-git-naming.md`](.cursor/rules/jira-git-naming.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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Program>`** 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.
|
||||
|
|
@ -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<GamePositionOptions>` 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`.
|
||||
|
|
@ -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<Program>();
|
||||
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<Program>();
|
||||
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<Program>();
|
||||
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<Program>();
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var content = new StringContent(
|
||||
"{\"schemaVersion\":1}",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
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;
|
||||
|
||||
/// <summary>Integration tests for <see cref="NeonSprawl.Server.Game.PositionState.PositionStateApi"/> GET routes.</summary>
|
||||
public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
||||
: IClassFixture<WebApplicationFactory<Program>>
|
||||
public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
|
||||
: IClassFixture<InMemoryWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
private readonly HttpClient httpClient = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task GetPosition_ShouldReturnVersionedPayloadAtOrigin_WhenDevPlayerRequested()
|
||||
|
|
@ -19,7 +18,7 @@ public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
|||
const string url = "/game/players/dev-local-1/position";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(url);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
|
@ -40,7 +39,7 @@ public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
|||
const string url = "/game/players/DEV-LOCAL-1/position";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(url);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
|
@ -58,7 +57,7 @@ public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
|||
const string url = "/game/players/unknown-player/position";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(url);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
[CollectionDefinition("Postgres integration")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresWebApplicationFactory>
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
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;
|
||||
|
||||
/// <summary>NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database.</summary>
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class PostgresPositionStateIntegrationTests(PostgresWebApplicationFactory 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<PositionStateResponse>("/game/players/dev-local-1/position");
|
||||
var post = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
|
||||
var postBody = await post.Content.ReadFromJsonAsync<PositionStateResponse>();
|
||||
var after = await client.GetFromJsonAsync<PositionStateResponse>("/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<PositionStateResponse>();
|
||||
}
|
||||
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondClient = secondFactory.CreateClient();
|
||||
var after = await secondClient.GetFromJsonAsync<PositionStateResponse>("/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);
|
||||
}
|
||||
|
||||
/// <summary>DDL + empty table + dev row matching the test host's <see cref="GamePositionOptions"/> (parity with startup seed).</summary>
|
||||
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<IOptions<GamePositionOptions>>().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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
/// <summary>Integration host with <see cref="PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName"/> bound from environment.</summary>
|
||||
public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
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<string, string?>
|
||||
{
|
||||
["ConnectionStrings:NeonSprawl"] = cs.Trim(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
/// <summary>Skips unless <c>ConnectionStrings__NeonSprawl</c> is set; fails in CI if missing so misconfigured workflows do not silently pass.</summary>
|
||||
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).";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>Forces the in-memory position store by replacing Postgres registrations after the host builds services (overrides process env e.g. CI <c>ConnectionStrings__NeonSprawl</c>).</summary>
|
||||
public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
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<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
|
|
@ -22,4 +23,8 @@
|
|||
<ProjectReference Include="..\NeonSprawl.Server\NeonSprawl.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\db\migrations\V001__player_position.sql" Link="db\migrations\V001__player_position.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>In-memory source of truth for player positions (NS-15 read API, NS-16 move apply).</summary>
|
||||
/// <summary>Authoritative player positions — in-memory or PostgreSQL per configuration (NS-15 read API, NS-16 move apply, NS-17 persistence).</summary>
|
||||
public interface IPositionStateStore
|
||||
{
|
||||
/// <summary>Returns false if the player is unknown (HTTP 404). <paramref name="playerId"/> is trimmed; matching is ordinal case-insensitive.</summary>
|
||||
|
|
|
|||
|
|
@ -4,31 +4,34 @@ using Microsoft.Extensions.Options;
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Thread-safe in-memory positions; seeds the configured dev player on construction.</summary>
|
||||
public sealed class InMemoryPositionStateStore : IPositionStateStore
|
||||
public sealed class InMemoryPositionStateStore(IOptions<GamePositionOptions> options) : IPositionStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PositionSnapshot> _positions = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, PositionSnapshot> positions = CreateInitialMap(options.Value);
|
||||
|
||||
public InMemoryPositionStateStore(IOptions<GamePositionOptions> options)
|
||||
private static ConcurrentDictionary<string, PositionSnapshot> CreateInitialMap(GamePositionOptions o)
|
||||
{
|
||||
var o = options.Value;
|
||||
var id = o.DevPlayerId.Trim();
|
||||
if (id.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
var map = new ConcurrentDictionary<string, PositionSnapshot>(StringComparer.OrdinalIgnoreCase);
|
||||
var p = o.DefaultPosition ?? new DefaultPositionOptions();
|
||||
_positions[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0);
|
||||
map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0);
|
||||
return map;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return _positions.TryGetValue(key, out snapshot);
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot)
|
||||
|
|
@ -42,18 +45,20 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore
|
|||
|
||||
while (true)
|
||||
{
|
||||
if (!_positions.TryGetValue(key, out var previous))
|
||||
if (!positions.TryGetValue(key, out var previous))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var next = new PositionSnapshot(x, y, z, previous.Sequence + 1);
|
||||
if (_positions.TryUpdate(key, next, previous))
|
||||
if (!positions.TryUpdate(key, next, previous))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
snapshot = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Registers in-memory position authority and options for NS-15.</summary>
|
||||
/// <summary>Registers position authority: PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set, otherwise in-memory (NS-15 / NS-17).</summary>
|
||||
public static class PositionStateServiceCollectionExtensions
|
||||
{
|
||||
private const string NeonSprawlConnectionStringName = "NeonSprawl";
|
||||
|
||||
public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<GamePositionOptions>(configuration.GetSection(GamePositionOptions.SectionName));
|
||||
|
||||
var cs = configuration.GetConnectionString(NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
var trimmed = cs.Trim();
|
||||
services.AddSingleton(_ => Npgsql.NpgsqlDataSource.Create(trimmed));
|
||||
services.AddHostedService<PostgresDevPlayerSeedHostedService>();
|
||||
services.AddSingleton<IPositionStateStore, PostgresPositionStateStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Runs DDL and dev-player seed once when the host starts (PostgreSQL path only).</summary>
|
||||
internal sealed class PostgresDevPlayerSeedHostedService(
|
||||
Npgsql.NpgsqlDataSource dataSource,
|
||||
IOptions<GamePositionOptions> positionOptions) : IHostedService
|
||||
{
|
||||
private readonly GamePositionOptions gameOptions = RequireDevPlayer(positionOptions.Value);
|
||||
|
||||
private static GamePositionOptions RequireDevPlayer(GamePositionOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.DevPlayerId))
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PostgresPositionBootstrap.EnsureSchema(dataSource);
|
||||
PostgresPositionBootstrap.SeedDevPlayer(dataSource, gameOptions);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>
|
||||
/// Applies NS-17 DDL (once per process, idempotent) and seeds the configured dev player row.
|
||||
/// Used at host startup and by integration tests after <c>TRUNCATE</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures <c>player_position</c> exists. Heavy work (read migration, run DDL) runs at most once per process.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Idempotent insert for <see cref="GamePositionOptions.DevPlayerId"/> at default position.</summary>
|
||||
public static void SeedDevPlayer(Npgsql.NpgsqlDataSource dataSource, GamePositionOptions options)
|
||||
{
|
||||
using var conn = dataSource.OpenConnection();
|
||||
SeedDevPlayer(conn, options);
|
||||
}
|
||||
|
||||
/// <summary>Idempotent insert using an existing open connection (e.g. test harness after <c>TRUNCATE</c>).</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>PostgreSQL-backed <see cref="IPositionStateStore"/> (NS-17). Player ids are stored normalized: invariant lower + trim for case-insensitive parity with <see cref="InMemoryPositionStateStore"/>.</summary>
|
||||
public sealed class PostgresPositionStateStore(
|
||||
Npgsql.NpgsqlDataSource dataSource,
|
||||
IOptions<GamePositionOptions> positionOptions) : IPositionStateStore
|
||||
{
|
||||
private readonly GamePositionOptions gameOptions = RequireValidOptions(positionOptions.Value);
|
||||
|
||||
private static GamePositionOptions RequireValidOptions(GamePositionOptions options)
|
||||
{
|
||||
if (options.DevPlayerId.Trim().Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,4 +14,16 @@
|
|||
<UseAppHost>false</UseAppHost>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="NeonSprawl.Server.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\db\migrations\*.sql" Link="db\migrations\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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 "<connection string>"` (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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).';
|
||||
Loading…
Reference in New Issue