9.5 KiB
NS-17 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NS-17 |
| Title | E1.M1: Persist character position (PostgreSQL) |
| Jira | NS-17 |
| Parent context | NS-10 — E1.M1 InputAndMovementRuntime · NS-16 (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 withPositionSnapshotand HTTP contract). - Repository / store implementation used by
IPositionStateStoresoPositionStateApistays unchanged. - Reuse
docker-compose.ymlPostgres from repo root / root README. - 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 toPositionSnapshot, no JSON parsing. Note this choice inserver/README.mdwhen describing persistence.
Acceptance criteria checklist
- Restart
dotnet runwith the same Postgres volume: last committed position is still returned fromGET /game/players/{id}/position(after a priorPOST …/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).
Technical approach
-
Configuration
- Introduce a connection string (e.g.
ConnectionStrings:NeonSprawlorDefault) inappsettings.Development.json/ environment overrides. Mirror docker-compose defaults already listed in rootREADME.md(localhost:5432,neon_sprawl,neon_sprawl/neon_sprawl_dev). AddPositionStateStore: when the connection string is present and non-empty, register a PostgreSQL-backedIPositionStateStore; otherwise keepInMemoryPositionStateStore(preserves CI and local tests without DB).
- Introduce a connection string (e.g.
-
Schema
- Add a versioned SQL file under the server tree (e.g.
server/db/migrations/V001__player_position.sql) withCREATE TABLE IF NOT EXISTS …and indexes needed for lookups. - Table name suggestion:
player_position(avoids implying a fullcharactersmodel). 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 matchInMemoryPositionStateStore(trim + ordinal case-insensitive semantics): implement withLOWER(TRIM(player_id))uniqueness / lookup pattern, or document that stored ids are normalized once — pick one approach and apply consistently in SQL + code.
- Add a versioned SQL file under the server tree (e.g.
-
Store implementation
- New class (e.g.
PostgresPositionStateStore) implementingIPositionStateStore, using Npgsql (NpgsqlDataSourceorNpgsqlConnectionvia DI) — add package toNeonSprawl.Server.csproj. - Seed dev player: on construction (or first DB touch), ensure the row for
Game:DevPlayerIdexists withGame:DefaultPositionandsequence = 0, analogous to in-memory startup seeding. TryGetPosition: read from DB; returnfalseif no row (unknown player).TryApplyMoveTarget: single transactionalUPDATEthat sets position, incrementssequence, bumpsupdated_at, and returns the new row (or use a concurrency-safe pattern). Unknown player →false. Preserve “404 for unknown id” behavior.
- New class (e.g.
-
Startup / DDL
- Either run idempotent DDL once at startup (lightweight for this story) or require
psql -f/ compose init — prefer idempotent startup ensure sodocker compose up+dotnet runworks without a manual step; keep the SQL file in repo as the source of truth.
- Either run idempotent DDL once at startup (lightweight for this story) or require
-
Documentation
- Update
server/README.md: persistence subsection (Postgres vs in-memory toggle), link to SQL file, note restart verification steps, document env vars (e.g.ConnectionStrings__NeonSprawlfor Docker / cloud) in addition to the literals already on the root README.
- Update
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). |
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 |
Add Npgsql (and any needed packages). |
server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs |
Register Postgres vs in-memory from configuration. |
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 |
Postgres persistence, env vars, manual restart acceptance check, how to run Postgres integration tests locally. |
.github/workflows/dotnet.yml |
Add a PostgreSQL service container (same image/credentials as 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
MoveCommandApiTestsandPositionStateApiTests: unchanged setup — no connection string so the host keepsInMemoryPositionStateStore— satisfies “no regression to in-memory happy path.” - New (required): PostgreSQL integration tests per
.cursor/rules/testing-expectations.md: useWebApplicationFactory<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 secondWebApplicationFactorywith 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.
- Cover at least: POST move then GET sees stored position and
- CI: .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: rundocker compose up -d(or any Postgres with matching credentials) and set the same connection string env vars the workflow uses, thendotnet 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
LOWERconstraints to avoid subtle 404 vs memory mismatches. - Service container vs Testcontainers: Prefer GitHub Actions
services: postgresfor 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.