Compare commits

...

17 Commits

Author SHA1 Message Date
VinPropane 83d350a341
Merge pull request #16 from ViPro-Technologies/docs/content-tooling-ct-track
chore: add CT content-tooling track and vision plan tooling epic
2026-03-30 23:19:08 -04:00
VinPropane 107eda77d8 chore: add CT content-tooling track and vision plan tooling epic
- Add docs/decomposition/tooling/internal_authoring.md and CT.M1–M3 module stubs
- Register CT modules and cross-cutting section in decomposition README
- Add internal-content-authoring execution plan
- Extend neon_sprawl_vision.plan.md with Tooling epic (CT) in Core Epic Map and modules
2026-03-30 23:18:40 -04:00
VinPropane 2b0a050645
Merge pull request #15 from ViPro-Technologies/NS-17-persist-position-postgres
NS-17: PostgreSQL position persistence and integration tests
2026-03-30 23:05:51 -04:00
VinPropane 715c7d4a1b NS-17: document Jira-prefixed commit subjects in rules and AGENTS
Link commit-and-review to jira-git-naming for story-scoped commits; clarify
jira-git-naming applies to commits done as part of a Jira issue; surface the
convention in AGENTS.md.
2026-03-30 23:04:49 -04:00
VinPropane d20b3931af style: primary constructors for position state types
Use C# primary constructors and camelCase private fields per csharp-style.
Refactor InMemoryPositionStateStore seeding into CreateInitialMap; align
Postgres integration tests and PositionStateApiTests with factory/httpClient
naming.
2026-03-30 23:01:36 -04:00
VinPropane e2d64a7a57 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.
2026-03-30 22:55:17 -04:00
VinPropane 6c3d8b164f
Merge pull request #14 from ViPro-Technologies/ci/pr-gate-workflow
Ci/pr gate workflow
2026-03-30 21:46:19 -04:00
VinPropane c3fc59347b Name PR gate job pr-gate for branch-protection search 2026-03-30 21:45:30 -04:00
VinPropane 52fe2d4a01 Run PR gate on main so required-checks list can discover it 2026-03-30 21:37:04 -04:00
VinPropane c37a99d4cd
Merge pull request #13 from ViPro-Technologies/ci/pr-gate-workflow
Run PR gate on feature-branch push, not only pull_request
2026-03-30 21:35:26 -04:00
VinPropane 0ac3fb07e9 Run PR gate on feature-branch push, not only pull_request 2026-03-30 21:34:13 -04:00
VinPropane 3b88bb2f2a
Merge pull request #12 from ViPro-Technologies/ci/pr-gate-workflow
Add PR gate workflow for path-skipped pull requests
2026-03-30 21:32:36 -04:00
VinPropane 510155d4fc Add PR gate workflow for path-skipped pull requests 2026-03-30 21:32:13 -04:00
VinPropane 2556f167c9
Merge pull request #11 from ViPro-Technologies/chore/add-gdlintrc
Add gdlintrc for GDScript linting configuration
2026-03-30 21:31:46 -04:00
VinPropane 1caf3e416f Add gdlintrc for GDScript linting configuration 2026-03-30 21:25:44 -04:00
VinPropane 6a804e591c
Merge pull request #10 from ViPro-Technologies/ci/gdscript-lint-and-dotnet-paths
ci: gate .NET workflow on server paths; add GDScript lint/format CI
2026-03-30 21:22:59 -04:00
VinPropane 15b0ee0921 ci: gate .NET workflow on server paths; add GDScript lint/format CI
- Run .NET build/test only when the solution, server/, or the workflow changes.
- Add gdlint and gdformat --check for client/ on relevant path changes.
- Align client GDScript with gdtoolkit (formatting, enum order, line length).
2026-03-30 21:16:16 -04:00
42 changed files with 1288 additions and 58 deletions

View File

@ -16,6 +16,11 @@ alwaysApply: true
- The user should **review** the diff before anything is committed. The agents 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.

View File

@ -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.

View File

@ -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`

View File

@ -4,12 +4,35 @@ name: .NET
on:
push:
branches: [main]
paths:
- "NeonSprawl.sln"
- "server/**"
- ".github/workflows/dotnet.yml"
pull_request:
branches: [main]
paths:
- "NeonSprawl.sln"
- "server/**"
- ".github/workflows/dotnet.yml"
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
@ -25,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

33
.github/workflows/gdscript.yml vendored 100644
View File

@ -0,0 +1,33 @@
# Lint and format-check GDScript (Godot 4). Uses gdtoolkit: https://github.com/Scony/godot-gdscript-toolkit
name: GDScript
on:
push:
branches: [main]
paths:
- "client/**/*.gd"
- ".github/workflows/gdscript.yml"
pull_request:
branches: [main]
paths:
- "client/**/*.gd"
- ".github/workflows/gdscript.yml"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install gdtoolkit
run: pip install "gdtoolkit==4.5.0"
- name: gdlint
run: gdlint client/
- name: gdformat (check only)
run: gdformat --check client/

22
.github/workflows/pr-gate.yml vendored 100644
View File

@ -0,0 +1,22 @@
# Path-filtered CI may skip; this always runs. Push includes main so the check
# runs on the default branch — GitHub only lists a workflow under “required status
# checks” after it has completed on the default branch at least once.
name: PR gate
on:
pull_request:
branches: [main]
push:
branches:
- "**"
jobs:
build:
# Branch-protection search matches job name, not workflow title — keep unique.
name: pr-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Gate
run: "true"

View File

@ -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).

View File

@ -5,7 +5,8 @@ extends Node3D
signal target_chosen(world: Vector3)
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` in `_ready`.
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd`
## in `_ready`.
var fallback_camera: Camera3D

View File

@ -1,6 +1,7 @@
extends Node3D
## NS-16: composes ground pick + server authority; see `ground_pick.gd` / `position_authority_client.gd`.
## NS-16: composes ground pick + server authority; see `ground_pick.gd` /
## `position_authority_client.gd`.
@onready var _camera: Camera3D = $World/Camera3D
@onready var _player: CharacterBody3D = $Player
@ -11,7 +12,9 @@ extends Node3D
func _ready() -> void:
_ground_pick.set("fallback_camera", _camera)
_ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen"))
_authority.connect("authoritative_position_received", Callable(self, "_on_authoritative_position"))
_authority.connect(
"authoritative_position_received", Callable(self, "_on_authoritative_position")
)
_authority.call("sync_from_server")

View File

@ -13,13 +13,13 @@ func _ready() -> void:
_goal = global_position
func set_move_goal(world_pos: Vector3) -> void:
_goal = world_pos
_goal.y = global_position.y
## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight the sync.
## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight
## the sync.
func snap_to_server(world_pos: Vector3) -> void:
global_position = world_pos
velocity = Vector3.ZERO

View File

@ -1,18 +1,19 @@
extends Node
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the avatar.
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
## avatar.
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
signal authoritative_position_received(world: Vector3)
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
var _http: HTTPRequest
var _busy: bool = false
enum _Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
var _phase: _Phase = _Phase.BOOT_GET
var _phase: Phase = Phase.BOOT_GET
func _ready() -> void:
@ -25,7 +26,7 @@ func sync_from_server() -> void:
if _busy:
return
_busy = true
_phase = _Phase.BOOT_GET
_phase = Phase.BOOT_GET
_request_get()
@ -33,7 +34,7 @@ func submit_move_target(world: Vector3) -> void:
if _busy:
return
_busy = true
_phase = _Phase.POST_MOVE
_phase = Phase.POST_MOVE
var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()]
var payload := {
"schemaVersion": 1,
@ -64,7 +65,9 @@ func _request_get() -> void:
_busy = false
func _on_request_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
func _on_request_completed(
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
if _result != HTTPRequest.RESULT_SUCCESS:
_busy = false
return
@ -72,17 +75,17 @@ func _on_request_completed(_result: int, response_code: int, _headers: PackedStr
var text := body.get_string_from_utf8()
match _phase:
_Phase.BOOT_GET:
Phase.BOOT_GET:
_busy = false
if response_code == 200:
_emit_position_from_response(text)
_Phase.POST_MOVE:
Phase.POST_MOVE:
if response_code != 200:
_busy = false
return
_phase = _Phase.VERIFY_GET
_phase = Phase.VERIFY_GET
_request_get()
_Phase.VERIFY_GET:
Phase.VERIFY_GET:
_busy = false
if response_code == 200:
_emit_position_from_response(text)

View File

@ -8,18 +8,19 @@ This workspace contains detailed planning artifacts derived from the finalized m
## Folder Layout
- `epics/` - One file per epic with implementation slices.
- `modules/` - [Contract definitions](modules/contracts.md), [client vs server authority](modules/client_server_authority.md), [PvP × combat engine](modules/pvp_combat_integration.md), [quest scope × party](modules/quest_scope_and_party.md), [data reload × telemetry ops](modules/data_and_ops_policy.md), [docs vs implementation](modules/documentation_and_implementation_alignment.md), [full module dependency register](modules/module_dependency_register.md) (all epic modules), [per-module docs](modules/module_dependency_register.md#per-module-documentation), and interface notes.
- `epics/` - One file per **player-facing** epic (E1E9) with implementation slices.
- `tooling/` - Cross-cutting **operator** tracks (e.g. content authoring) that are **not** numbered with the nine core epics; same slice style, separate **CT.\*** module IDs.
- `modules/` - [Contract definitions](modules/contracts.md), [client vs server authority](modules/client_server_authority.md), [PvP × combat engine](modules/pvp_combat_integration.md), [quest scope × party](modules/quest_scope_and_party.md), [data reload × telemetry ops](modules/data_and_ops_policy.md), [docs vs implementation](modules/documentation_and_implementation_alignment.md), [full module dependency register](modules/module_dependency_register.md) (E1E9 and CT modules), [per-module docs](modules/module_dependency_register.md#per-module-documentation), and interface notes.
- `milestones/` - Phase-gated implementation tracks and pass/fail checklists.
## Working Rules
1. Do not rewrite baseline vision in these files; link back to the master plan.
2. Keep slices backlog-ready (clear owner, dependencies, acceptance criteria).
3. Use stable IDs (E1, E1.M1, P1.M1, etc.) to preserve traceability.
3. Use stable IDs (`E1`, `E1.M1`, etc.) for core game epics; use **`CT.M1`**-style IDs for [content tooling](tooling/internal_authoring.md) so tooling stays out of the E1E9 sequence.
4. Every new slice must reference telemetry and validation where applicable.
## Epic Files
## Epic files (E1E9, master plan)
| Epic | Document |
|------|----------|
@ -35,6 +36,12 @@ This workspace contains detailed planning artifacts derived from the finalized m
Template for new epics or major revisions: [epics/_epic_template.md](epics/_epic_template.md)
## Cross-cutting tooling (not E1E9)
| Track | Document | Modules (register) |
|-------|----------|-------------------|
| Internal authoring & content pipeline | [tooling/internal_authoring.md](tooling/internal_authoring.md) | **CT.M1M3** |
## Next Actions
1. Expand slices into engineering tickets; keep module IDs aligned with the master plan.

View File

@ -0,0 +1,45 @@
# CT.M1 — ContentValidationPipeline
## Summary
| Field | Value |
|--------|--------|
| **Module ID** | CT.M1 |
| **Track** | [Content tooling — internal authoring](../tooling/internal_authoring.md) (cross-cutting; not E1E9) |
| **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
## Purpose
Own **content schema artifacts**, **automated validation** in CI and locally, and (when wired) **server-side smoke loading** so file-backed catalogs cannot drift from enforced contracts.
## Responsibilities
- Publish and version **JSON Schema** (or agreed equivalent) for catalogs consumed by **E2.M1**, **E3.M2M3**, **E4.M1**, **E7.M1**.
- Run **structural** and **cross-reference** validation before merge; expose the same rules to **CT.M2**.
- Optional: **C#** deserialize test or boot check aligned with `ContentCatalogRegistry` (or successor).
## Key contracts
| Contract | Role |
|----------|------|
| Content schema files | `$id` + versioning per [contracts.md](contracts.md) |
| Validation CLI | Stable exit codes and machine-readable errors for CI and studio |
## Module dependencies
- **Soft:** Gameplay modules above must define or stabilize the **shapes** being validated; pipeline can ship **minimal** schemas first.
## Dependents (by design)
- **CT.M2** — AuthoringStudioApplication
- **CT.M3** — ContentReferenceAndBundleWorkflows
## Related implementation slices
Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hardening.
## Source anchors
- Plan: [`internal-content-authoring.md`](../../plans/internal-content-authoring.md)
- [Module dependency register](module_dependency_register.md)

View File

@ -0,0 +1,45 @@
# CT.M2 — AuthoringStudioApplication
## Summary
| Field | Value |
|--------|--------|
| **Module ID** | CT.M2 |
| **Track** | [Content tooling — internal authoring](../tooling/internal_authoring.md) (cross-cutting; not E1E9) |
| **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
## Purpose
Internal **authoring UI** (TypeScript-first per [tech stack](../../architecture/tech_stack.md)) for editing **`content/**`** with **validation parity** with CI and a practical **zone graph** authoring experience.
## Responsibilities
- Browse and edit catalog files; **forms** and **pickers** for IDs (items, zones, quests) as catalogs exist.
- **Topology:** Visual **graph** for **ZoneDef** / **ZoneEdge** / **TravelRule** aligned with **E4.M1**.
- **Security:** Internal-only deployment; filesystem or local API access must not become part of the public game surface.
## Key contracts
| Contract | Role |
|----------|------|
| Studio ↔ repo | Read/write JSON (or YAML) under agreed roots; no separate authoring DB as source of truth |
| Validator integration | Invokes **CT.M1** rules for inline feedback |
## Module dependencies
- **CT.M1** — ContentValidationPipeline
## Dependents (by design)
- **CT.M3** — optional integrated UX for reference index and bundles
## Related implementation slices
Content tooling **Slice 2** — studio core + topology editor.
## Source anchors
- Plan: [`internal-content-authoring.md`](../../plans/internal-content-authoring.md)
- [E4.M1 — ZoneGraphAndTravelRules](E4_M1_ZoneGraphAndTravelRules.md)
- [Module dependency register](module_dependency_register.md)

View File

@ -0,0 +1,45 @@
# CT.M3 — ContentReferenceAndBundleWorkflows
## Summary
| Field | Value |
|--------|--------|
| **Module ID** | CT.M3 |
| **Track** | [Content tooling — internal authoring](../tooling/internal_authoring.md) (cross-cutting; not E1E9) |
| **Stage target** | Pre-production |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
## Purpose
**Workflow depth** after the MVP studio: project-wide **reference graph**, safer **renames**, **schema version** warnings across files, and optional **content bundle** manifests to reduce client/server drift.
## Responsibilities
- Build an index of IDs and references across `content/**` for find-usages and rename planning.
- Emit optional **hash/version manifest** for CI and deploy pipelines (aligns with tech stack bundle note).
- Document operational practices (PR review for content, bundle promotion).
## Key contracts
| Contract | Role |
|----------|------|
| `ContentBundleManifest` (optional) | Lists files, schema versions, content hashes |
| Reference report | Machine-readable graph for CI or review tools |
## Module dependencies
- **CT.M1** — ContentValidationPipeline
- **CT.M2** — AuthoringStudioApplication (recommended for integrated UX)
## Dependents (by design)
- None within the CT track; ops/deploy may consume manifests.
## Related implementation slices
Content tooling **Slice 3** — reference index and optional bundles.
## Source anchors
- Plan: [`internal-content-authoring.md`](../../plans/internal-content-authoring.md)
- [Module dependency register](module_dependency_register.md)

View File

@ -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

View File

@ -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) |
---

View File

@ -104,6 +104,18 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
| E9.M3 | LiveBalanceControlPlane | E9.M2, E5.M1, E2.M4, E3.M5 | BalancePatch, ConfigVersion, RolloutState | Production | Planned |
| E9.M4 | IntegrityAndAbuseResponse | E9.M1 | IntegritySignal, IncidentTicket, EnforcementAction | Pre-production | Planned |
### Cross-cutting — Content tooling (not E1E9)
Operator-facing authoring infrastructure; module IDs use the **CT** prefix. Program doc: [`tooling/internal_authoring.md`](../tooling/internal_authoring.md).
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|---|---|---|---|---|---|
| CT.M1 | ContentValidationPipeline | Soft: E2.M1, E3.M2M3, E4.M1, E7.M1 (shape alignment) | JSON Schema bundle, validation CLI, error schema | Prototype | Planned |
| CT.M2 | AuthoringStudioApplication | CT.M1 | Studio project layout, optional local content API | Prototype | Planned |
| CT.M3 | ContentReferenceAndBundleWorkflows | CT.M1; CT.M2 (recommended) | ContentBundleManifest (optional), reference graph | Pre-production | Planned |
**CT.\*** modules consume **gameplay content** contracts from E1E9; they do not replace **E7** runtime quest semantics or **E4** server travel evaluation.
## Per-module documentation
| Module ID | Document |
@ -145,6 +157,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
| E9.M2 | [E9_M2_KpiDashboardsAndAlerting.md](E9_M2_KpiDashboardsAndAlerting.md) |
| E9.M3 | [E9_M3_LiveBalanceControlPlane.md](E9_M3_LiveBalanceControlPlane.md) |
| E9.M4 | [E9_M4_IntegrityAndAbuseResponse.md](E9_M4_IntegrityAndAbuseResponse.md) |
| CT.M1 | [CT_M1_ContentValidationPipeline.md](CT_M1_ContentValidationPipeline.md) |
| CT.M2 | [CT_M2_AuthoringStudioApplication.md](CT_M2_AuthoringStudioApplication.md) |
| CT.M3 | [CT_M3_ContentReferenceAndBundleWorkflows.md](CT_M3_ContentReferenceAndBundleWorkflows.md) |
## Status Legend

View File

@ -0,0 +1,99 @@
# Internal authoring and content tooling (cross-cutting)
This document is the **program epic** for operator-facing authoring infrastructure. It sits **alongside** the [nine player-facing epics](../README.md) from the master plan (E1E9): same decomposition style (modules, slices, acceptance criteria), but **not** a tenth numbered player epic. It is captured in the vision plan as the **Tooling epic — Internal authoring and content pipeline (CT)** ([`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Core Epic Map and **Content tooling modules (CT)**).
**Module IDs** use the **CT** prefix (**C**ontent **T**ooling): **CT.M1M3**. That keeps E1.M1-style IDs reserved for the core game epics.
## Source anchors
- **Execution plan (phasing detail):** [`docs/plans/internal-content-authoring.md`](../../plans/internal-content-authoring.md)
- Master plan — **tooling epic + CT modules:** [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) (Core Epic Map; System Modules — Content tooling **CT**)
- Master plan — product/content assumptions: data-driven catalogs; TS/JS for tooling per [tech stack](../../architecture/tech_stack.md)
- **Contracts:** [`docs/decomposition/modules/contracts.md`](../modules/contracts.md)
- Related modules: **CT.M1**, **CT.M2**, **CT.M3**. **Consumes** gameplay content contract *shapes* from **E2.M1**, **E3.M2M3**, **E4.M1**, **E7.M1** (schemas and loaders must stay aligned as those modules evolve).
## Ownership and success (Level 1)
- **Ownership focus:** Content Engineering + Tools + Build/CI
- **Success criteria:** Solo or small team can author **zones**, **items**, **recipes**, and **quests** quickly, with **CI and local validation** preventing broken references before merge; **no** shadow databases as source of truth.
## Objective
Deliver an **internal authoring stack**—schemas, validators, optional authoring UI, and workflow helpers—so bulk game data stays **file-backed**, **versioned**, and **consistent** with the authoritative server and with decomposition contracts. This track complements **Epic 347** (data *consumption* in the live game) by owning the **pipeline** that produces and checks that data.
## Module breakdown
### CT.M1 - ContentValidationPipeline
- **Responsibility:** Canonical JSON (or YAML) Schemas for content catalogs; **CI** and **local** validation (syntax + cross-table rules); optional **server boot** or **test** smoke load that deserializes catalogs.
- **Key contracts:** Schema documents (`$id` + version folders per [`contracts.md`](../modules/contracts.md)); validation CLI; error format consumed by studio.
- **Dependencies:** Stable enough shapes from **E2/E3/E4/E7** content contracts (can start minimal and extend).
- **Stage target:** Prototype
### CT.M2 - AuthoringStudioApplication
- **Responsibility:** Internal **TypeScript** application (e.g. Vite + React) for browsing `content/`, editing catalogs with **forms** and **ID pickers**, and running the same validation as CI; **zone graph** visualization and editing for **E4.M1**-shaped payloads.
- **Key contracts:** Read/write paths to repo `content/**`; optional tiny local HTTP API for filesystem access in browser dev mode.
- **Dependencies:** **CT.M1** (validator behavior and schema versions).
- **Stage target:** Prototype
### CT.M3 - ContentReferenceAndBundleWorkflows
- **Responsibility:** Project-wide **reference index** (find references, safer **rename** suggestions); warnings for **mixed schema versions** in one bundle; optional **content bundle manifest** + hash for client/server drift control (aligns with tech stack “bundles” note).
- **Key contracts:** `ContentBundleManifest` (optional); reference graph export for CI or review.
- **Dependencies:** **CT.M1**; **CT.M2** recommended for UX.
- **Stage target:** Pre-production
## Implementation slices (backlog-ready)
### Slice 1 - Schemas and CI validation (MVP pipeline)
- **Scope:** **CT.M1** — publish schemas for skills, items, recipes, quests, and zone graph; wire **PR CI** so invalid content cannot merge; document validate commands in [`content/README.md`](../../../content/README.md) or tooling README.
- **Dependencies:** Agreement on canonical schema path (`contracts/schemas/content/` vs co-located); see [`internal-content-authoring.md`](../../plans/internal-content-authoring.md).
- **Acceptance criteria:**
- Every `content/**/*.json` (or agreed glob) is validated on PR.
- Cross-table rules catch at least: missing **item** IDs on recipes, missing **zone** IDs on edges, missing **quest** IDs on travel rules where applicable.
- **Telemetry / signals:** CI job success/failure; optional `content_validation_failed` counter in ingest (internal) — **not** required for player telemetry.
### Slice 2 - Authoring studio core + topology editor
- **Scope:** **CT.M2** — file tree, JSON editing with validation feedback, **zone graph** editor (nodes/edges/`TravelRule` forms), save back to repo files.
- **Dependencies:** **CT.M1** stable for catalog types in use.
- **Acceptance criteria:**
- Author can add a **zone**, **edge**, and **recipe** in one session without hand-editing invalid JSON (validator passes on save or shows blocking errors).
- Internal runbook documents **localhost/VPN** usage; no exposure on public game API.
- **Telemetry / signals:** None required; optional anonymous local usage metrics off by default.
### Slice 3 - Reference index and optional bundles
- **Scope:** **CT.M3** — find references / rename helpers; optional bundle manifest for staging/prod artifact parity.
- **Dependencies:** **CT.M1**, **CT.M2** (for integrated UX).
- **Acceptance criteria:**
- Renaming an **item** ID surfaces all JSON references before apply.
- If bundles are in scope: manifest lists file hashes and schema versions; documented in ops runbook.
- **Telemetry / signals:** CI validates manifest integrity on build.
### Slice 4 - Server/content parity hardening (optional gate)
- **Scope:** **CT.M1** extension — `dotnet test` or startup path proves **ContentCatalogRegistry** (or equivalent) loads all files the server expects; fails CI when C# models drift from schema.
- **Dependencies:** Server project owns loader types; coordinate with **E3.M3 / E7.M1** implementation timing.
- **Acceptance criteria:**
- One **test** or **host build step** fails if any catalog file is rejected by the server deserializer.
- **Telemetry / signals:** CI only.
**Scope note (Epic 7):** Epic 7 already lists “Tools” under ownership for narrative/quest work; this **CT** track owns **shared** authoring infrastructure (schemas, CI, studio shell). Quest-specific UX can land in **CT.M2** slices while **E7.M1** owns runtime `QuestDef` semantics.
## Risks and mitigations
- **Risk:** Tooling drifts from server deserialization.
- **Mitigation:** Slice 4 parity test; single schema source of truth; align with [`contracts.md`](../modules/contracts.md).
- **Risk:** Authors bypass validation locally and break `main`.
- **Mitigation:** Required CI on `content/**`; pre-commit hook optional.
- **Risk:** Internal studio exposed publicly.
- **Mitigation:** Separate host/port, auth per [tech stack — security](../../architecture/tech_stack.md); VPN/local default.
## Definition of done
- **Prototype:** **CT.M1** + **CT.M2** slices 12 complete; designers can author core catalogs and zone graphs with CI enforcement.
- **Pre-production:** **CT.M3** reference workflows; optional bundles documented.
- Player-facing epics **3 / 4 / 7** remain the authority for **what** data means at runtime; this track is done when **changing** that data is safe, fast, and traceable in git.

View File

@ -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.

View File

@ -0,0 +1,102 @@
# Internal world and content authoring (plan)
**Status:** Planning only — not an implementation backlog ticket. Use this doc to decide scope, phasing, and ownership before building tooling.
**Vision plan:** **Tooling epic — Internal authoring and content pipeline (CT)** in [`neon_sprawl_vision.plan.md](../../neon_sprawl_vision.plan.md) (Core Epic Map + **Content tooling modules (CT)** under System Modules).
**Decomposition:** [Internal authoring (cross-cutting)](../decomposition/tooling/internal_authoring.md) — modules **CT.M1M3** (not numbered with core epics E1E9).
**Related:** [Tech stack — content pipeline](../architecture/tech_stack.md), [Contract kinds — Content](../decomposition/modules/contracts.md), [content/README.md](../../content/README.md).
---
## Why this exists
Neon Sprawl is **data-driven** for skills, items, recipes, quests, and (eventually) world topology. An **internal authoring stack** reduces friction for world designers and content authors: valid IDs, consistent JSON, and a fast loop from edit → CI → server/client consumption — without giving the game client authority over definitions.
---
## Context in the repo (today)
- **Content:** [`content/`](../../content/) holds catalogs; skills exist today with a co-located schema under `content/schemas/`. Items, recipes, quests, and zone graphs will follow the same pattern as those modules land.
- **Decomposition:** Shapes are named in epics and modules — e.g. `ItemDef` / `RecipeDef` ([Epic 3 — Crafting](../decomposition/epics/epic_03_crafting_economy.md)), `ZoneDef` / `ZoneEdge` / `TravelRule` ([Epic 4 — World topology](../decomposition/epics/epic_04_world_topology.md)), `QuestDef` ([Epic 7 — Quest / Faction](../decomposition/epics/epic_07_quest_faction.md)), with detail in [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) and [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md).
- **Stack:** The [tech stack](../architecture/tech_stack.md) locks **authoritative** simulation to **C#** and allows **TypeScript/JavaScript** for **content tooling** (validators, internal UIs, one-off scripts).
---
## Goals (v1 of the tooling program)
1. **Single source of truth:** Definitions live in **versioned files** in git (JSON/YAML), not a private database, unless a later phase adds an explicit export from a temp store.
2. **Fast iteration:** Authors get **inline validation**, **ID pickers** (items, zones, quests), and clear errors before merge.
3. **Topology:** Support **zone graph** authoring (nodes, edges, travel rules) aligned with **E4.M1**; treat seamless region handoff ([E4.M3](../decomposition/modules/E4_M3_SeamlessHandoffAndRegionState.md)) as a later editor concern once the sim consumes it.
4. **Safety:** Internal-only use; any HTTP surface for the tool stays **separate** from the public game API and uses **proportionate auth** per phase ([tech stack — security](../architecture/tech_stack.md)).
---
## Non-goals (first release)
- Replacing Godot (or a DCC) for **full 3D world sculpting** — optional **import/preview** later.
- **Client-authoritative** definitions — the tool **authors data**; the **server** remains authoritative at runtime.
---
## Target architecture (high level)
| Piece | Role |
|--------|------|
| **Content files + JSON Schema** | Canonical definitions; validate in **CI** and locally ([contracts — Content kind](../decomposition/modules/contracts.md)). |
| **Validator** | Same rules in CI and in the editor: schema compliance + **cross-table checks** (e.g. recipe inputs reference real `ItemDef` IDs). |
| **Authoring UI** | Small **TypeScript** app (e.g. Vite + React) or desktop shell (Tauri/Electron) for filesystem-backed projects — decision at kickoff. |
| **Server** | Loads the same files at boot (and optional **smoke tests** that deserialize all catalogs). |
| **Godot (optional)** | Read-only debug preview of district/zone data — not required for MVP. |
---
## Phased roadmap (suggested)
### Phase 0 — Contracts and validation spine
- Publish minimal **JSON Schemas** for `ItemDef`, `RecipeDef`, `QuestDef`, and zone graph payloads; align **one** canonical schema location with [contracts.md](../decomposition/modules/contracts.md) (`contracts/schemas/content/` vs co-located — pick once).
- Wire **CI** so `content/**/*.json` cannot merge invalid (Node `ajv` / CLI or `dotnet` validator).
- Optional: **dotnet** test that **loads** all content (catches deserialization drift).
### Phase 1 — “Content Studio” MVP
- **File-backed** project: browse `content/`, edit with **forms** guided by schema (hand-built or generated).
- **Topology:** Visual **graph** for zones + edges + `TravelRule` forms; export to the agreed JSON shape.
- **Economy / quests:** Tables or forms for items, recipes, quest steps with **pickers** for IDs.
- **Ergonomics:** `npm run validate`, optional watch mode, pre-commit hook if desired.
### Phase 2 — Workflow polish
- **Reference index** for find-references and safer renames across files.
- **Schema versioning** policy per contracts doc; warn on mixed versions in one bundle.
- **Optional bundles:** Hash-versioned content blobs for client/server drift control ([tech stack](../architecture/tech_stack.md)).
### Phase 3 — Depth (when simulation supports it)
- Spawn / ecology authoring ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
- Spatial helpers (Godot marker export, CSV import, 2D overlays if `ZoneDef` gains coordinates).
- Documented flows for applying bulk content updates to **staging** DBs while **git** remains truth.
---
## Open decisions (before implementation)
- **UI shell:** Browser + local API vs Tauri/Electron for native “open folder.”
- **Authoring format default:** JSON vs YAML for hand editing.
- **Zone graph storage:** One graph file per district vs split `zones` / `edges` files — trade merge conflicts vs navigation.
---
## Success metrics (when you implement)
- Adding **item + recipe + quest** that ties them together takes **minutes**, with **zero** validation surprises on `main`.
- Zone graph edits do not introduce **illegal transitions** in playtests relative to server **TravelRule** evaluation.
---
## Security and operations
- Run the tool on **VPN/localhost** until hardening is explicit.
- **Audit trail:** prefer **git history**; use PR review for `content/` if the team grows.

View File

@ -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 **lowmedium**: 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`.

47
gdlintrc 100644
View File

@ -0,0 +1,47 @@
class-definitions-order:
- tools
- classnames
- extends
- docstrings
- signals
- enums
- consts
- staticvars
- exports
- pubvars
- prvvars
- onreadypubvars
- onreadyprvvars
- others
class-load-variable-name: (([A-Z][a-z0-9]*)+|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
class-name: ([A-Z][a-z0-9]*)+
class-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
comparison-with-itself: null
constant-name: _?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*
disable: []
duplicated-load: null
enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*'
enum-name: ([A-Z][a-z0-9]*)+
excluded_directories: !!set
.git: null
expression-not-assigned: null
function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
function-arguments-number: 10
function-name: (_on_([A-Z][a-z0-9]*)+(_[a-z0-9]+)*|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
function-preload-variable-name: ([A-Z][a-z0-9]*)+
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
max-file-lines: 1000
max-line-length: 100
max-public-methods: 20
max-returns: 6
mixed-tabs-and-spaces: null
no-elif-return: null
no-else-return: null
signal-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
sub-class-name: _?([A-Z][a-z0-9]*)+
tab-characters: 1
trailing-whitespace: null
unnecessary-pass: null
unused-argument: null

View File

@ -346,6 +346,14 @@ Transform the validated prototype into a scalable production-ready development m
- **Scope:** Metrics pipeline, tuning controls, event tooling, anti-cheat/anti-bot workflows, incident runbooks.
- **Success criteria:** Team can operate, balance, and protect the game continuously post-launch with fast feedback loops.
### Tooling epic — Internal authoring and content pipeline (CT)
**Type:** Operator- and build-focused **tooling epic**, parallel to the numbered **E1E9** player/product epics above. It does **not** extend the sequence to “Epic 10”; modules use the **`CT.M#`** prefix. Full program doc (slices, risks, DoD): [`docs/decomposition/tooling/internal_authoring.md`](docs/decomposition/tooling/internal_authoring.md).
- **Ownership focus:** Content Engineering + Tools + Build/CI
- **Scope:** JSON (or YAML) schemas for catalogs, **CI and local validation** (including cross-table reference checks), optional **internal authoring UI** (TypeScript-friendly per tech stack), reference/rename workflows, optional content bundles; consumes contract *shapes* from **E2 / E3 / E4 / E7** as those stabilize.
- **Success criteria:** Zones, items, recipes, and quests can be authored as **file-backed, versioned** data with merge gates that prevent broken references; no separate authoring database as source of truth.
## System Modules by Epic (Level 2)
Module template:
@ -566,6 +574,26 @@ Module template:
- Dependencies: E9.M1.
- Stage target: Pre-production.
### Content tooling modules (CT) — Internal authoring
**Tooling track** (not E1E9). Depends **softly** on catalog shapes from **E2.M1**, **E3.M2M3**, **E4.M1**, **E7.M1**; see [`docs/decomposition/tooling/internal_authoring.md`](docs/decomposition/tooling/internal_authoring.md).
- **CT.M1 ContentValidationPipeline**
- Responsibility: Canonical schemas for content files; CI and local validation; optional server/test smoke load of catalogs.
- Key contracts: JSON Schema bundle, validation CLI, stable error format for editors.
- Dependencies: Soft alignment with E2/E3/E4/E7 content contracts; can start with minimal schemas.
- Stage target: Prototype.
- **CT.M2 AuthoringStudioApplication**
- Responsibility: Internal authoring app (e.g. Vite + React) for `content/**`, forms and ID pickers, zone graph editing aligned with **E4.M1** payloads.
- Key contracts: Repo read/write paths; optional local API for dev browser; invokes **CT.M1** rules.
- Dependencies: CT.M1.
- Stage target: Prototype.
- **CT.M3 ContentReferenceAndBundleWorkflows**
- Responsibility: Project-wide reference index, safer renames, schema-version warnings; optional bundle manifest for client/server parity.
- Key contracts: `ContentBundleManifest` (optional), reference graph for CI/review.
- Dependencies: CT.M1; CT.M2 recommended for integrated UX.
- Stage target: Pre-production.
## Module Dependency Flow (Level 2)
```mermaid

View File

@ -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}",

View File

@ -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);

View File

@ -0,0 +1,8 @@
using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState;
[CollectionDefinition("Postgres integration")]
public sealed class PostgresCollection : ICollectionFixture<PostgresWebApplicationFactory>
{
}

View File

@ -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);
}
}

View File

@ -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(),
});
});
}
}

View File

@ -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).";
}
}

View File

@ -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>();
});
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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))
{
snapshot = next;
return true;
continue;
}
snapshot = next;
return true;
}
}
}

View File

@ -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
{

View File

@ -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));
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
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;
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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 stores 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)

View File

@ -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).';