Compare commits

..

No commits in common. "83d350a3418e6050861641793b409698e9c3433f" and "a8cbcb5ff71085ce9480d07577d2fa47187d9d9f" have entirely different histories.

42 changed files with 58 additions and 1288 deletions

View File

@ -16,11 +16,6 @@ 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. - 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). - 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 ## 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. - 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,19 +36,6 @@ public sealed class OrderService(IOrderStore store, ILogger<OrderService> log)
## Layout and syntax ## Layout and syntax
- **Braces:** opening brace `{` on a **new line** for types and members (Allman-style), per common Microsoft examples. - **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. - **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. - **`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. - **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 ## Commit messages
- 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. - **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` - **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` - **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,35 +4,12 @@ name: .NET
on: on:
push: push:
branches: [main] branches: [main]
paths:
- "NeonSprawl.sln"
- "server/**"
- ".github/workflows/dotnet.yml"
pull_request: pull_request:
branches: [main] branches: [main]
paths:
- "NeonSprawl.sln"
- "server/**"
- ".github/workflows/dotnet.yml"
jobs: jobs:
build: build:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -48,7 +25,4 @@ jobs:
run: dotnet build NeonSprawl.sln --no-restore --configuration Release run: dotnet build NeonSprawl.sln --no-restore --configuration Release
- name: Test - 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 run: dotnet test NeonSprawl.sln --no-build --configuration Release --verbosity normal

View File

@ -1,33 +0,0 @@
# 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/

View File

@ -1,22 +0,0 @@
# 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,5 +7,3 @@ 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 | | **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). 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,8 +5,7 @@ extends Node3D
signal target_chosen(world: Vector3) signal target_chosen(world: Vector3)
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` ## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` in `_ready`.
## in `_ready`.
var fallback_camera: Camera3D var fallback_camera: Camera3D

View File

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

View File

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

View File

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

View File

@ -8,19 +8,18 @@ This workspace contains detailed planning artifacts derived from the finalized m
## Folder Layout ## Folder Layout
- `epics/` - One file per **player-facing** epic (E1E9) with implementation slices. - `epics/` - One file per epic 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) (all epic modules), [per-module docs](modules/module_dependency_register.md#per-module-documentation), and interface notes.
- `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. - `milestones/` - Phase-gated implementation tracks and pass/fail checklists.
## Working Rules ## Working Rules
1. Do not rewrite baseline vision in these files; link back to the master plan. 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). 2. Keep slices backlog-ready (clear owner, dependencies, acceptance criteria).
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. 3. Use stable IDs (E1, E1.M1, P1.M1, etc.) to preserve traceability.
4. Every new slice must reference telemetry and validation where applicable. 4. Every new slice must reference telemetry and validation where applicable.
## Epic files (E1E9, master plan) ## Epic Files
| Epic | Document | | Epic | Document |
|------|----------| |------|----------|
@ -36,12 +35,6 @@ 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) 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 ## Next Actions
1. Expand slices into engineering tickets; keep module IDs aligned with the master plan. 1. Expand slices into engineering tickets; keep module IDs aligned with the master plan.

View File

@ -1,45 +0,0 @@
# 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

@ -1,45 +0,0 @@
# 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

@ -1,45 +0,0 @@
# 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 ## Implementation snapshot
- **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). - **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:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. - **Not yet:** Persistence, prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry.
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
## Module dependencies ## 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 | | Module | Register status | Snapshot | Plans / pointers |
|--------|-----------------|----------|-------------------| |--------|-----------------|----------|-------------------|
| 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) | | 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) |
--- ---

View File

@ -104,18 +104,6 @@ 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.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 | | 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 ## Per-module documentation
| Module ID | Document | | Module ID | Document |
@ -157,9 +145,6 @@ Operator-facing authoring infrastructure; module IDs use the **CT** prefix. Prog
| E9.M2 | [E9_M2_KpiDashboardsAndAlerting.md](E9_M2_KpiDashboardsAndAlerting.md) | | E9.M2 | [E9_M2_KpiDashboardsAndAlerting.md](E9_M2_KpiDashboardsAndAlerting.md) |
| E9.M3 | [E9_M3_LiveBalanceControlPlane.md](E9_M3_LiveBalanceControlPlane.md) | | E9.M3 | [E9_M3_LiveBalanceControlPlane.md](E9_M3_LiveBalanceControlPlane.md) |
| E9.M4 | [E9_M4_IntegrityAndAbuseResponse.md](E9_M4_IntegrityAndAbuseResponse.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 ## Status Legend

View File

@ -1,99 +0,0 @@
# 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

@ -1,96 +0,0 @@
# 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

@ -1,102 +0,0 @@
# 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

@ -1,71 +0,0 @@
# 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`.

View File

@ -1,47 +0,0 @@
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,14 +346,6 @@ 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. - **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. - **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) ## System Modules by Epic (Level 2)
Module template: Module template:
@ -574,26 +566,6 @@ Module template:
- Dependencies: E9.M1. - Dependencies: E9.M1.
- Stage target: Pre-production. - 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) ## Module Dependency Flow (Level 2)
```mermaid ```mermaid

View File

@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using Xunit; using Xunit;
@ -16,7 +17,7 @@ public class MoveCommandApiTests
public async Task PostMove_ShouldPersistTargetAndIncrementSequence_WhenDevPlayerPostsValidMove() public async Task PostMove_ShouldPersistTargetAndIncrementSequence_WhenDevPlayerPostsValidMove()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient(); var client = factory.CreateClient();
var cmd = new MoveCommandRequest var cmd = new MoveCommandRequest
{ {
@ -54,7 +55,7 @@ public class MoveCommandApiTests
public async Task PostMove_ShouldReturnNotFound_WhenPlayerIsUnknown() public async Task PostMove_ShouldReturnNotFound_WhenPlayerIsUnknown()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient(); var client = factory.CreateClient();
var cmd = new MoveCommandRequest var cmd = new MoveCommandRequest
{ {
@ -73,7 +74,7 @@ public class MoveCommandApiTests
public async Task PostMove_ShouldReturnBadRequest_WhenSchemaVersionIsWrong() public async Task PostMove_ShouldReturnBadRequest_WhenSchemaVersionIsWrong()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient(); var client = factory.CreateClient();
var cmd = new MoveCommandRequest var cmd = new MoveCommandRequest
{ {
@ -92,7 +93,7 @@ public class MoveCommandApiTests
public async Task PostMove_ShouldReturnBadRequest_WhenTargetIsMissing() public async Task PostMove_ShouldReturnBadRequest_WhenTargetIsMissing()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient(); var client = factory.CreateClient();
var content = new StringContent( var content = new StringContent(
"{\"schemaVersion\":1}", "{\"schemaVersion\":1}",

View File

@ -1,15 +1,16 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState; namespace NeonSprawl.Server.Tests.Game.PositionState;
/// <summary>Integration tests for <see cref="NeonSprawl.Server.Game.PositionState.PositionStateApi"/> GET routes.</summary> /// <summary>Integration tests for <see cref="NeonSprawl.Server.Game.PositionState.PositionStateApi"/> GET routes.</summary>
public class PositionStateApiTests(InMemoryWebApplicationFactory factory) public class PositionStateApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<InMemoryWebApplicationFactory> : IClassFixture<WebApplicationFactory<Program>>
{ {
private readonly HttpClient httpClient = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
[Fact] [Fact]
public async Task GetPosition_ShouldReturnVersionedPayloadAtOrigin_WhenDevPlayerRequested() public async Task GetPosition_ShouldReturnVersionedPayloadAtOrigin_WhenDevPlayerRequested()
@ -18,7 +19,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
const string url = "/game/players/dev-local-1/position"; const string url = "/game/players/dev-local-1/position";
// Act // Act
var response = await httpClient.GetAsync(url); var response = await _client.GetAsync(url);
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@ -39,7 +40,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
const string url = "/game/players/DEV-LOCAL-1/position"; const string url = "/game/players/DEV-LOCAL-1/position";
// Act // Act
var response = await httpClient.GetAsync(url); var response = await _client.GetAsync(url);
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@ -57,7 +58,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
const string url = "/game/players/unknown-player/position"; const string url = "/game/players/unknown-player/position";
// Act // Act
var response = await httpClient.GetAsync(url); var response = await _client.GetAsync(url);
// Assert // Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

View File

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

View File

@ -1,137 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,33 +0,0 @@
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,7 +9,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" 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="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
@ -23,8 +22,4 @@
<ProjectReference Include="..\NeonSprawl.Server\NeonSprawl.Server.csproj" /> <ProjectReference Include="..\NeonSprawl.Server\NeonSprawl.Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="..\db\migrations\V001__player_position.sql" Link="db\migrations\V001__player_position.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>

View File

@ -1,6 +1,6 @@
namespace NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Authoritative player positions — in-memory or PostgreSQL per configuration (NS-15 read API, NS-16 move apply, NS-17 persistence).</summary> /// <summary>In-memory source of truth for player positions (NS-15 read API, NS-16 move apply).</summary>
public interface IPositionStateStore public interface IPositionStateStore
{ {
/// <summary>Returns false if the player is unknown (HTTP 404). <paramref name="playerId"/> is trimmed; matching is ordinal case-insensitive.</summary> /// <summary>Returns false if the player is unknown (HTTP 404). <paramref name="playerId"/> is trimmed; matching is ordinal case-insensitive.</summary>

View File

@ -4,36 +4,33 @@ using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Thread-safe in-memory positions; seeds the configured dev player on construction.</summary> /// <summary>Thread-safe in-memory positions; seeds the configured dev player on construction.</summary>
public sealed class InMemoryPositionStateStore(IOptions<GamePositionOptions> options) : IPositionStateStore public sealed class InMemoryPositionStateStore : IPositionStateStore
{ {
private readonly ConcurrentDictionary<string, PositionSnapshot> positions = CreateInitialMap(options.Value); private readonly ConcurrentDictionary<string, PositionSnapshot> _positions = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentDictionary<string, PositionSnapshot> CreateInitialMap(GamePositionOptions o) public InMemoryPositionStateStore(IOptions<GamePositionOptions> options)
{ {
var o = options.Value;
var id = o.DevPlayerId.Trim(); var id = o.DevPlayerId.Trim();
if (id.Length == 0) if (id.Length == 0)
{
throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
}
var map = new ConcurrentDictionary<string, PositionSnapshot>(StringComparer.OrdinalIgnoreCase);
var p = o.DefaultPosition ?? new DefaultPositionOptions(); var p = o.DefaultPosition ?? new DefaultPositionOptions();
map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); _positions[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0);
return map;
} }
public bool TryGetPosition(string playerId, out PositionSnapshot snapshot) public bool TryGetPosition(string playerId, out PositionSnapshot snapshot)
{ {
var key = playerId?.Trim(); var key = playerId?.Trim();
if (!string.IsNullOrEmpty(key)) if (string.IsNullOrEmpty(key))
{ {
return positions.TryGetValue(key, out snapshot);
}
snapshot = default; snapshot = default;
return false; return false;
} }
return _positions.TryGetValue(key, out snapshot);
}
public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot) public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot)
{ {
var key = playerId?.Trim(); var key = playerId?.Trim();
@ -45,20 +42,18 @@ public sealed class InMemoryPositionStateStore(IOptions<GamePositionOptions> opt
while (true) while (true)
{ {
if (!positions.TryGetValue(key, out var previous)) if (!_positions.TryGetValue(key, out var previous))
{ {
snapshot = default; snapshot = default;
return false; return false;
} }
var next = new PositionSnapshot(x, y, z, previous.Sequence + 1); var next = new PositionSnapshot(x, y, z, previous.Sequence + 1);
if (!positions.TryUpdate(key, next, previous)) if (_positions.TryUpdate(key, next, previous))
{ {
continue;
}
snapshot = next; snapshot = next;
return true; return true;
} }
} }
} }
}

View File

@ -10,9 +10,7 @@ public static class PositionStateApi
(string id, IPositionStateStore store) => (string id, IPositionStateStore store) =>
{ {
if (!store.TryGetPosition(id, out var snap)) if (!store.TryGetPosition(id, out var snap))
{
return Results.NotFound(); return Results.NotFound();
}
var body = new PositionStateResponse var body = new PositionStateResponse
{ {
@ -28,21 +26,17 @@ public static class PositionStateApi
"/game/players/{id}/move", "/game/players/{id}/move",
(string id, MoveCommandRequest? body, IPositionStateStore store) => (string id, MoveCommandRequest? body, IPositionStateStore store) =>
{ {
if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) if (body is null)
{ return Results.BadRequest();
if (body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion)
return Results.BadRequest(); return Results.BadRequest();
}
var target = body.Target; var target = body.Target;
if (target is null) if (target is null)
{
return Results.BadRequest(); return Results.BadRequest();
}
if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap)) if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap))
{
return Results.NotFound(); return Results.NotFound();
}
var response = new PositionStateResponse var response = new PositionStateResponse
{ {

View File

@ -1,27 +1,12 @@
namespace NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Registers position authority: PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set, otherwise in-memory (NS-15 / NS-17).</summary> /// <summary>Registers in-memory position authority and options for NS-15.</summary>
public static class PositionStateServiceCollectionExtensions public static class PositionStateServiceCollectionExtensions
{ {
private const string NeonSprawlConnectionStringName = "NeonSprawl";
public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<GamePositionOptions>(configuration.GetSection(GamePositionOptions.SectionName)); services.Configure<GamePositionOptions>(configuration.GetSection(GamePositionOptions.SectionName));
var cs = configuration.GetConnectionString(NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
var trimmed = cs.Trim();
services.AddSingleton(_ => Npgsql.NpgsqlDataSource.Create(trimmed));
services.AddHostedService<PostgresDevPlayerSeedHostedService>();
services.AddSingleton<IPositionStateStore, PostgresPositionStateStore>();
}
else
{
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
}
return services; return services;
} }
} }

View File

@ -1,30 +0,0 @@
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

@ -1,84 +0,0 @@
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

@ -1,106 +0,0 @@
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,16 +14,4 @@
<UseAppHost>false</UseAppHost> <UseAppHost>false</UseAppHost>
</PropertyGroup> </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> </Project>

View File

@ -1,6 +1,6 @@
# Game server (`NeonSprawl.Server`) # Game server (`NeonSprawl.Server`)
ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + position state via in-memory **or** PostgreSQL). ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + in-memory position read API).
## Prerequisites ## Prerequisites
@ -16,28 +16,9 @@ dotnet run
- Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`). - Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`).
- Check `GET /health` for a JSON heartbeat. - 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) ## Position state (NS-15)
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. 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.
Example (dev player id defaults to `dev-local-1` in `appsettings.json`): Example (dev player id defaults to `dev-local-1` in `appsettings.json`):
@ -51,7 +32,7 @@ Sample response:
{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":0,"y":0,"z":0},"sequence":0} {"schemaVersion":1,"playerId":"dev-local-1","position":{"x":0,"y":0,"z":0},"sequence":0}
``` ```
Unknown player ids return **404**. Full zone sync / replication is still out of scope for these spikes. Unknown player ids return **404**. This endpoint is a **spike** until durable persistence and full sync exist.
## Move command (NS-16) ## Move command (NS-16)

View File

@ -1,11 +0,0 @@
-- 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).';