Compare commits
17 Commits
a8cbcb5ff7
...
83d350a341
| Author | SHA1 | Date |
|---|---|---|
|
|
83d350a341 | |
|
|
107eda77d8 | |
|
|
2b0a050645 | |
|
|
715c7d4a1b | |
|
|
d20b3931af | |
|
|
e2d64a7a57 | |
|
|
6c3d8b164f | |
|
|
c3fc59347b | |
|
|
52fe2d4a01 | |
|
|
c37a99d4cd | |
|
|
0ac3fb07e9 | |
|
|
3b88bb2f2a | |
|
|
510155d4fc | |
|
|
2556f167c9 | |
|
|
1caf3e416f | |
|
|
6a804e591c | |
|
|
15b0ee0921 |
|
|
@ -16,6 +16,11 @@ alwaysApply: true
|
|||
- The user should **review** the diff before anything is committed. The agent’s job is to make the changes visible (uncommitted) and explain them; the user decides when to commit.
|
||||
- If the user asks to commit, use a message that matches repo conventions; still follow [git workflow](git-workflow.md) (branch vs `main`, doc-only vs code).
|
||||
|
||||
## Commit message format when a Jira story applies
|
||||
|
||||
- Any commit that is **part of implementing or delivering a Jira story or task** must put the **Jira issue key first** in the subject line, then **`:`**, then the summary (e.g. `NS-17: persist position state in PostgreSQL`).
|
||||
- Infer the key from the active branch name, the issue under discussion, or Jira context. Full rules (multi-issue commits, `chore:` when there is no ticket) are in [jira-git-naming](jira-git-naming.md).
|
||||
|
||||
## Pull request and push descriptions
|
||||
|
||||
- Do **not** add **“Made-with: Cursor”**, **“Generated with Cursor”**, tool co-author lines, or similar AI/IDE boilerplate to **PR descriptions**, **GitHub merge/squash commit bodies** you draft, or other **remote-facing** narrative unless the user explicitly requests it.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,19 @@ public sealed class OrderService(IOrderStore store, ILogger<OrderService> log)
|
|||
## Layout and syntax
|
||||
|
||||
- **Braces:** opening brace `{` on a **new line** for types and members (Allman-style), per common Microsoft examples.
|
||||
- **Braces for every block:** never omit `{ }` on `if`, `else`, `for`, `foreach`, `while`, `do`, or `using` when the language allows a single statement without braces—**always** use a braced block, even for one line. This avoids accidental logic changes when editing. Expression-bodied members and expression lambdas (`x => x.Id`) stay valid when the whole body is a single expression.
|
||||
|
||||
```csharp
|
||||
// Prefer
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Avoid
|
||||
if (string.IsNullOrEmpty(key))
|
||||
return false;
|
||||
```
|
||||
- **Indentation:** **4 spaces** per level; no tabs unless the file already uses tabs—never mix.
|
||||
- **`var`:** use when the type is obvious from the right-hand side; use explicit types when it improves readability or for literals where the type matters.
|
||||
- **File-scoped namespaces** (`namespace X;`) for new single-namespace files when the SDK/version allows.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ When suggesting or creating a branch for story **NS-14**, use something like **`
|
|||
|
||||
## Commit messages
|
||||
|
||||
- **First token** of the subject line must be the **Jira key and number**, then **`:`** and the summary.
|
||||
- For **any commit done as part of a Jira story, bug, or task** (work tracked under an issue), the **first token** of the subject line must be the **Jira key and number**, then **`:`** and the summary.
|
||||
- **Good:** `NS-14: add direct click-to-move steering`, `NS-20: fix duplicate spawn on reconnect`
|
||||
- **Avoid:** `fix(client): …` or `feat: …` **without** the Jira key at the front. If you use Conventional Commits, place them **after** the key: `NS-14: feat(client): click-to-move prototype`
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,35 @@ name: .NET
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "NeonSprawl.sln"
|
||||
- "server/**"
|
||||
- ".github/workflows/dotnet.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "NeonSprawl.sln"
|
||||
- "server/**"
|
||||
- ".github/workflows/dotnet.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: neon_sprawl
|
||||
POSTGRES_PASSWORD: neon_sprawl_dev
|
||||
POSTGRES_DB: neon_sprawl
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U neon_sprawl -d neon_sprawl"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -25,4 +48,7 @@ jobs:
|
|||
run: dotnet build NeonSprawl.sln --no-restore --configuration Release
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
ConnectionStrings__NeonSprawl: >-
|
||||
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
||||
run: dotnet test NeonSprawl.sln --no-build --configuration Release --verbosity normal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# Lint and format-check GDScript (Godot 4). Uses gdtoolkit: https://github.com/Scony/godot-gdscript-toolkit
|
||||
name: GDScript
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "client/**/*.gd"
|
||||
- ".github/workflows/gdscript.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "client/**/*.gd"
|
||||
- ".github/workflows/gdscript.yml"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install gdtoolkit
|
||||
run: pip install "gdtoolkit==4.5.0"
|
||||
|
||||
- name: gdlint
|
||||
run: gdlint client/
|
||||
|
||||
- name: gdformat (check only)
|
||||
run: gdformat --check client/
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Path-filtered CI may skip; this always runs. Push includes main so the check
|
||||
# runs on the default branch — GitHub only lists a workflow under “required status
|
||||
# checks” after it has completed on the default branch at least once.
|
||||
name: PR gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Branch-protection search matches job name, not workflow title — keep unique.
|
||||
name: pr-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Gate
|
||||
run: "true"
|
||||
|
|
@ -7,3 +7,5 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
|
|||
| **Code review** | [`.cursor/rules/code-review-agent.md`](.cursor/rules/code-review-agent.md) | PR / diff / pre-merge review; **always writes** `docs/reviews/YYYY-MM-DD-{slug}.md` with **Documentation checked** vs `docs/plans/` and `docs/decomposition/modules/`; short chat pointer |
|
||||
|
||||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **PR / push text:** no “Made-with: Cursor” boilerplate — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md).
|
||||
|
||||
**Commits tied to a Jira issue** must start the subject with the **issue key** and a colon (e.g. `NS-17: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/jira-git-naming.md`](.cursor/rules/jira-git-naming.md).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ extends Node3D
|
|||
|
||||
signal target_chosen(world: Vector3)
|
||||
|
||||
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` in `_ready`.
|
||||
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd`
|
||||
## in `_ready`.
|
||||
var fallback_camera: Camera3D
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
extends Node3D
|
||||
|
||||
## NS-16: composes ground pick + server authority; see `ground_pick.gd` / `position_authority_client.gd`.
|
||||
## NS-16: composes ground pick + server authority; see `ground_pick.gd` /
|
||||
## `position_authority_client.gd`.
|
||||
|
||||
@onready var _camera: Camera3D = $World/Camera3D
|
||||
@onready var _player: CharacterBody3D = $Player
|
||||
|
|
@ -11,7 +12,9 @@ extends Node3D
|
|||
func _ready() -> void:
|
||||
_ground_pick.set("fallback_camera", _camera)
|
||||
_ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen"))
|
||||
_authority.connect("authoritative_position_received", Callable(self, "_on_authoritative_position"))
|
||||
_authority.connect(
|
||||
"authoritative_position_received", Callable(self, "_on_authoritative_position")
|
||||
)
|
||||
_authority.call("sync_from_server")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ func _ready() -> void:
|
|||
_goal = global_position
|
||||
|
||||
|
||||
|
||||
func set_move_goal(world_pos: Vector3) -> void:
|
||||
_goal = world_pos
|
||||
_goal.y = global_position.y
|
||||
|
||||
|
||||
## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight the sync.
|
||||
## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight
|
||||
## the sync.
|
||||
func snap_to_server(world_pos: Vector3) -> void:
|
||||
global_position = world_pos
|
||||
velocity = Vector3.ZERO
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
extends Node
|
||||
|
||||
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the avatar.
|
||||
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
|
||||
## avatar.
|
||||
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
|
||||
|
||||
signal authoritative_position_received(world: Vector3)
|
||||
|
||||
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
|
||||
var _http: HTTPRequest
|
||||
var _busy: bool = false
|
||||
|
||||
enum _Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
|
||||
var _phase: _Phase = _Phase.BOOT_GET
|
||||
var _phase: Phase = Phase.BOOT_GET
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -25,7 +26,7 @@ func sync_from_server() -> void:
|
|||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
_phase = _Phase.BOOT_GET
|
||||
_phase = Phase.BOOT_GET
|
||||
_request_get()
|
||||
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ func submit_move_target(world: Vector3) -> void:
|
|||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
_phase = _Phase.POST_MOVE
|
||||
_phase = Phase.POST_MOVE
|
||||
var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()]
|
||||
var payload := {
|
||||
"schemaVersion": 1,
|
||||
|
|
@ -64,7 +65,9 @@ func _request_get() -> void:
|
|||
_busy = false
|
||||
|
||||
|
||||
func _on_request_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
func _on_request_completed(
|
||||
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
if _result != HTTPRequest.RESULT_SUCCESS:
|
||||
_busy = false
|
||||
return
|
||||
|
|
@ -72,17 +75,17 @@ func _on_request_completed(_result: int, response_code: int, _headers: PackedStr
|
|||
var text := body.get_string_from_utf8()
|
||||
|
||||
match _phase:
|
||||
_Phase.BOOT_GET:
|
||||
Phase.BOOT_GET:
|
||||
_busy = false
|
||||
if response_code == 200:
|
||||
_emit_position_from_response(text)
|
||||
_Phase.POST_MOVE:
|
||||
Phase.POST_MOVE:
|
||||
if response_code != 200:
|
||||
_busy = false
|
||||
return
|
||||
_phase = _Phase.VERIFY_GET
|
||||
_phase = Phase.VERIFY_GET
|
||||
_request_get()
|
||||
_Phase.VERIFY_GET:
|
||||
Phase.VERIFY_GET:
|
||||
_busy = false
|
||||
if response_code == 200:
|
||||
_emit_position_from_response(text)
|
||||
|
|
|
|||
|
|
@ -8,18 +8,19 @@ This workspace contains detailed planning artifacts derived from the finalized m
|
|||
|
||||
## Folder Layout
|
||||
|
||||
- `epics/` - One file per epic with implementation slices.
|
||||
- `modules/` - [Contract definitions](modules/contracts.md), [client vs server authority](modules/client_server_authority.md), [PvP × combat engine](modules/pvp_combat_integration.md), [quest scope × party](modules/quest_scope_and_party.md), [data reload × telemetry ops](modules/data_and_ops_policy.md), [docs vs implementation](modules/documentation_and_implementation_alignment.md), [full module dependency register](modules/module_dependency_register.md) (all epic modules), [per-module docs](modules/module_dependency_register.md#per-module-documentation), and interface notes.
|
||||
- `epics/` - One file per **player-facing** epic (E1–E9) 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) (E1–E9 and CT modules), [per-module docs](modules/module_dependency_register.md#per-module-documentation), and interface notes.
|
||||
- `milestones/` - Phase-gated implementation tracks and pass/fail checklists.
|
||||
|
||||
## Working Rules
|
||||
|
||||
1. Do not rewrite baseline vision in these files; link back to the master plan.
|
||||
2. Keep slices backlog-ready (clear owner, dependencies, acceptance criteria).
|
||||
3. Use stable IDs (E1, E1.M1, P1.M1, etc.) to preserve traceability.
|
||||
3. Use stable IDs (`E1`, `E1.M1`, etc.) for core game epics; use **`CT.M1`**-style IDs for [content tooling](tooling/internal_authoring.md) so tooling stays out of the E1–E9 sequence.
|
||||
4. Every new slice must reference telemetry and validation where applicable.
|
||||
|
||||
## Epic Files
|
||||
## Epic files (E1–E9, master plan)
|
||||
|
||||
| Epic | Document |
|
||||
|------|----------|
|
||||
|
|
@ -35,6 +36,12 @@ This workspace contains detailed planning artifacts derived from the finalized m
|
|||
|
||||
Template for new epics or major revisions: [epics/_epic_template.md](epics/_epic_template.md)
|
||||
|
||||
## Cross-cutting tooling (not E1–E9)
|
||||
|
||||
| Track | Document | Modules (register) |
|
||||
|-------|----------|-------------------|
|
||||
| Internal authoring & content pipeline | [tooling/internal_authoring.md](tooling/internal_authoring.md) | **CT.M1–M3** |
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. Expand slices into engineering tickets; keep module IDs aligned with the master plan.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
# CT.M1 — ContentValidationPipeline
|
||||
|
||||
## Summary
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Module ID** | CT.M1 |
|
||||
| **Track** | [Content tooling — internal authoring](../tooling/internal_authoring.md) (cross-cutting; not E1–E9) |
|
||||
| **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.M2–M3**, **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)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# CT.M2 — AuthoringStudioApplication
|
||||
|
||||
## Summary
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Module ID** | CT.M2 |
|
||||
| **Track** | [Content tooling — internal authoring](../tooling/internal_authoring.md) (cross-cutting; not E1–E9) |
|
||||
| **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)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# CT.M3 — ContentReferenceAndBundleWorkflows
|
||||
|
||||
## Summary
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Module ID** | CT.M3 |
|
||||
| **Track** | [Content tooling — internal authoring](../tooling/internal_authoring.md) (cross-cutting; not E1–E9) |
|
||||
| **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)
|
||||
|
|
@ -33,8 +33,8 @@ Contract readiness is tracked in the [module dependency register](module_depende
|
|||
|
||||
## Implementation snapshot
|
||||
|
||||
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); in-memory store; Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`).
|
||||
- **Not yet:** Persistence, prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry.
|
||||
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17).
|
||||
- **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry.
|
||||
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||
|
||||
## Module dependencies
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
|||
|
||||
| Module | Register status | Snapshot | Plans / pointers |
|
||||
|--------|-----------------|----------|-------------------|
|
||||
| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); in-memory store; Godot client sync (NS-16). Persistence / prediction / full slice still open. | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`; [server README — Position / move](../../../server/README.md) |
|
||||
| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client sync (NS-16). Prediction / full slice still open. | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), [NS-17](../../plans/NS-17-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`; [server README — Position / persistence](../../../server/README.md) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,18 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
| E9.M3 | LiveBalanceControlPlane | E9.M2, E5.M1, E2.M4, E3.M5 | BalancePatch, ConfigVersion, RolloutState | Production | Planned |
|
||||
| E9.M4 | IntegrityAndAbuseResponse | E9.M1 | IntegritySignal, IncidentTicket, EnforcementAction | Pre-production | Planned |
|
||||
|
||||
### Cross-cutting — Content tooling (not E1–E9)
|
||||
|
||||
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.M2–M3, 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 E1–E9; they do not replace **E7** runtime quest semantics or **E4** server travel evaluation.
|
||||
|
||||
## Per-module documentation
|
||||
|
||||
| Module ID | Document |
|
||||
|
|
@ -145,6 +157,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
| E9.M2 | [E9_M2_KpiDashboardsAndAlerting.md](E9_M2_KpiDashboardsAndAlerting.md) |
|
||||
| E9.M3 | [E9_M3_LiveBalanceControlPlane.md](E9_M3_LiveBalanceControlPlane.md) |
|
||||
| E9.M4 | [E9_M4_IntegrityAndAbuseResponse.md](E9_M4_IntegrityAndAbuseResponse.md) |
|
||||
| CT.M1 | [CT_M1_ContentValidationPipeline.md](CT_M1_ContentValidationPipeline.md) |
|
||||
| CT.M2 | [CT_M2_AuthoringStudioApplication.md](CT_M2_AuthoringStudioApplication.md) |
|
||||
| CT.M3 | [CT_M3_ContentReferenceAndBundleWorkflows.md](CT_M3_ContentReferenceAndBundleWorkflows.md) |
|
||||
|
||||
## Status Legend
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
# Internal authoring and content tooling (cross-cutting)
|
||||
|
||||
This document is the **program epic** for operator-facing authoring infrastructure. It sits **alongside** the [nine player-facing epics](../README.md) from the master plan (E1–E9): 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.M1–M3**. 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.M2–M3**, **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 3–4–7** (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 1–2 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.
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
# NS-17 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NS-17 |
|
||||
| **Title** | E1.M1: Persist character position (PostgreSQL) |
|
||||
| **Jira** | [NS-17](https://neon-sprawl.atlassian.net/browse/NS-17) |
|
||||
| **Parent context** | [NS-10 — E1.M1 InputAndMovementRuntime](https://neon-sprawl.atlassian.net/browse/NS-10) · [NS-16](NS-16-implementation-plan.md) (MoveCommand → `PositionState`) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** **`PositionState` survives server restart** via a thin PostgreSQL persistence path wired into the existing GET/POST endpoints.
|
||||
|
||||
**In scope**
|
||||
|
||||
- SQL migration or script for a minimal table: player identifier, position fields, monotonic **`sequence`**, **`updated_at`** (aligned with [`PositionSnapshot`](../../server/NeonSprawl.Server/Game/PositionState/PositionSnapshot.cs) and HTTP contract).
|
||||
- Repository / store implementation used by **`IPositionStateStore`** so [`PositionStateApi`](../../server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs) stays unchanged.
|
||||
- Reuse **[`docker-compose.yml`](../../docker-compose.yml)** Postgres from repo root / [root README](../../README.md).
|
||||
- Document how the server picks the database (configuration / env vars).
|
||||
|
||||
**Out of scope (per Jira)**
|
||||
|
||||
- Full character creation flow, multiple zones, ORM/migration framework polish beyond what is needed for this slice.
|
||||
|
||||
**Persistence contract (columns vs JSONB)**
|
||||
|
||||
- Use **relational columns** (`pos_x`, `pos_y`, `pos_z`, `sequence`, `updated_at`) for the row — simple queries, clear mapping to `PositionSnapshot`, no JSON parsing. Note this choice in `server/README.md` when describing persistence.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Restart **`dotnet run`** with the same Postgres volume: last committed position is still returned from **`GET /game/players/{id}/position`** (after a prior **`POST …/move`**).
|
||||
- [ ] Connection / database configuration documented (env vars or `ConnectionStrings`, aligned with ASP.NET conventions).
|
||||
- [ ] No regression to the **in-memory** happy path: default **`dotnet test`** / CI without Postgres continues to behave as NS-15/NS-16 (see [Tests](#tests)).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Configuration**
|
||||
- Introduce a **connection string** (e.g. `ConnectionStrings:NeonSprawl` or `Default`) in `appsettings.Development.json` / environment overrides. Mirror docker-compose defaults already listed in [root `README.md`](../../README.md) (`localhost:5432`, `neon_sprawl`, `neon_sprawl` / `neon_sprawl_dev`).
|
||||
- **`AddPositionStateStore`**: when the connection string is **present and non-empty**, register a **PostgreSQL-backed** `IPositionStateStore`; otherwise keep **`InMemoryPositionStateStore`** (preserves CI and local tests without DB).
|
||||
|
||||
2. **Schema**
|
||||
- Add a versioned SQL file under the server tree (e.g. `server/db/migrations/V001__player_position.sql`) with `CREATE TABLE IF NOT EXISTS …` and indexes needed for lookups.
|
||||
- Table name suggestion: **`player_position`** (avoids implying a full **`characters`** model). Columns: `player_id` (text, primary key or unique), `pos_x`, `pos_y`, `pos_z` (`double precision`), `sequence` (`bigint`), `updated_at` (`timestamptz`). Case-insensitive id matching should match [`InMemoryPositionStateStore`](../../server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs) (trim + ordinal case-insensitive semantics): implement with **`LOWER(TRIM(player_id))`** uniqueness / lookup pattern, or document that stored ids are normalized once — **pick one approach and apply consistently in SQL + code**.
|
||||
|
||||
3. **Store implementation**
|
||||
- New class (e.g. **`PostgresPositionStateStore`**) implementing **`IPositionStateStore`**, using **Npgsql** (`NpgsqlDataSource` or `NpgsqlConnection` via DI) — add package to [`NeonSprawl.Server.csproj`](../../server/NeonSprawl.Server/NeonSprawl.Server.csproj).
|
||||
- **Seed dev player:** on construction (or first DB touch), ensure the row for **`Game:DevPlayerId`** exists with **`Game:DefaultPosition`** and **`sequence = 0`**, analogous to in-memory startup seeding.
|
||||
- **`TryGetPosition`:** read from DB; return **`false`** if no row (unknown player).
|
||||
- **`TryApplyMoveTarget`:** single transactional **`UPDATE`** that sets position, increments **`sequence`**, bumps **`updated_at`**, and returns the new row (or use a concurrency-safe pattern). Unknown player → **`false`**. Preserve “404 for unknown id” behavior.
|
||||
|
||||
4. **Startup / DDL**
|
||||
- Either run idempotent DDL once at startup (lightweight for this story) **or** require `psql -f` / compose init — **prefer idempotent startup ensure** so `docker compose up` + `dotnet run` works without a manual step; keep the SQL file in repo as the source of truth.
|
||||
|
||||
5. **Documentation**
|
||||
- Update **[`server/README.md`](../../server/README.md):** persistence subsection (Postgres vs in-memory toggle), link to SQL file, note restart verification steps, document **env vars** (e.g. `ConnectionStrings__NeonSprawl` for Docker / cloud) in addition to the literals already on the root README.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|--------|
|
||||
| `server/db/migrations/V001__player_position.sql` (or similar) | Idempotent DDL for `player_position` (+ comments). |
|
||||
| `server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs` (name may vary) | Npgsql-backed `IPositionStateStore` + seed + read/update. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs` (name may vary) | xUnit integration tests against **real PostgreSQL** (HTTP + persistence boundary; see [Tests](#tests)). |
|
||||
| Optional: `.../PositionState/DatabasePositionOptions.cs` | Section binding for connection string name / feature flag if needed. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| [`server/NeonSprawl.Server/NeonSprawl.Server.csproj`](../../server/NeonSprawl.Server/NeonSprawl.Server.csproj) | Add **Npgsql** (and any needed packages). |
|
||||
| [`server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs`](../../server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs) | Register Postgres vs in-memory from configuration. |
|
||||
| [`server/NeonSprawl.Server/appsettings.json`](../../server/NeonSprawl.Server/appsettings.json) / `appsettings.Development.json` | Optional default connection string for local dev (or leave empty to default in-memory). |
|
||||
| [`server/README.md`](../../server/README.md) | Postgres persistence, env vars, manual restart acceptance check, how to run **Postgres integration tests** locally. |
|
||||
| [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml) | Add a **PostgreSQL service container** (same image/credentials as [`docker-compose.yml`](../../docker-compose.yml)) and pass a **connection string** into the **Test** step so Postgres integration tests run on every push/PR that touches the server. |
|
||||
|
||||
## Tests
|
||||
|
||||
- **Existing** [`MoveCommandApiTests`](../../server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs) and [`PositionStateApiTests`](../../server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs): unchanged setup — **no** connection string so the host keeps **`InMemoryPositionStateStore`** — satisfies “no regression to in-memory happy path.”
|
||||
- **New (required):** **PostgreSQL integration tests** per [`.cursor/rules/testing-expectations.md`](../../.cursor/rules/testing-expectations.md): use **`WebApplicationFactory<Program>`** with configuration (or environment) that sets the same **connection string** the app uses for the durable store, pointing at real Postgres.
|
||||
- Cover at least: **POST move** then **GET** sees stored position and **`sequence`**; **persistence across host restarts** simulated by **disposing** the first factory and creating a **second** `WebApplicationFactory` with the same connection string, then **GET** still returns the last committed state (matches Jira “restart server + DB” without a literal process kill).
|
||||
- Optionally add a **failure-path** case (e.g. unknown player **404** on move when row absent) if the implementation keeps that behavior for Postgres.
|
||||
- **CI:** [.github/workflows/dotnet.yml](../../.github/workflows/dotnet.yml) must run these tests **always** in the existing job (service container + env), not as an optional manual job — so the durable path is gated like the rest of the suite.
|
||||
- **Local:** document in **`server/README.md`**: run **`docker compose up -d`** (or any Postgres with matching credentials) and set the same connection string env vars the workflow uses, then `dotnet test`.
|
||||
|
||||
**Manual verification (Jira AC, optional double-check):** `docker compose up -d` → `dotnet run` with Postgres enabled → `POST …/move` → stop server → `dotnet run` again → `GET …/position` matches last move.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Case sensitivity:** DB must match current **trim + case-insensitive** player id semantics; enforce via normalized column or `LOWER` constraints to avoid subtle 404 vs memory mismatches.
|
||||
- **Service container vs Testcontainers:** Prefer **GitHub Actions `services: postgres`** for CI (no new test dependency). If flakiness appears, consider **Testcontainers** later; initial implementation should not defer integration tests.
|
||||
|
||||
## Pull request description (draft)
|
||||
|
||||
**NS-17:** Persist `PositionState` in PostgreSQL when a connection string is configured; keep in-memory store when not. Adds DDL + Npgsql-backed `IPositionStateStore`, **PostgreSQL integration tests** (including simulating restart via a second web factory), **Postgres service in `dotnet.yml`**, and README notes for env vars and local test runs.
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# Internal world and content authoring (plan)
|
||||
|
||||
**Status:** Planning only — not an implementation backlog ticket. Use this doc to decide scope, phasing, and ownership before building tooling.
|
||||
|
||||
**Vision plan:** **Tooling epic — Internal authoring and content pipeline (CT)** in [`neon_sprawl_vision.plan.md](../../neon_sprawl_vision.plan.md) (Core Epic Map + **Content tooling modules (CT)** under System Modules).
|
||||
|
||||
**Decomposition:** [Internal authoring (cross-cutting)](../decomposition/tooling/internal_authoring.md) — modules **CT.M1–M3** (not numbered with core epics E1–E9).
|
||||
|
||||
**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.
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Code review — NS-17 (PostgreSQL position persistence)
|
||||
|
||||
**Date:** 2026-03-30
|
||||
|
||||
**Scope:** Branch `NS-17-persist-position-postgres`; Jira [NS-17](https://neon-sprawl.atlassian.net/browse/NS-17). Working tree includes **modified** and **untracked** files (e.g. `PostgresPositionStateStore.cs`, `server/db/`, Postgres test types are **untracked** at review time).
|
||||
|
||||
**Base:** `main` (comparison: `git diff main --stat` plus untracked NS-17 assets).
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — Persistence wiring matches the NS-17 plan and authority docs; tests and CI cover the durable path. Decomposition **implementation snapshot** docs updated for NS-17 (see **Author follow-up** below).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This work adds a PostgreSQL-backed `IPositionStateStore`, a versioned DDL file under `server/db/migrations/`, configuration via `ConnectionStrings:NeonSprawl`, idempotent schema application once per process, and per-request idempotent dev-row seeding so behavior stays consistent with TRUNCATE-heavy tests. GitHub Actions gains a Postgres service and passes the connection string into `dotnet test`. In-memory API tests are isolated via `InMemoryWebApplicationFactory` + `ConfigureTestServices` so a global CI env var does not poison the in-memory suite. Overall risk is **low–medium**: prototype-grade lifecycle (no explicit `NpgsqlDataSource` disposal), runtime DDL from the app, and every request paying a seed `INSERT` (no-op when row exists).
|
||||
|
||||
The diff also includes **repo-wide C# brace style** updates in `.cursor/rules/csharp-style.md` and touched server files — acceptable but **mixed scope** vs NS-17; consider separate commit or callout in PR.
|
||||
|
||||
---
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Assessment |
|
||||
|----------|------------|
|
||||
| `docs/plans/NS-17-implementation-plan.md` | **Matches** — columns not JSONB, `player_position`, `IPositionStateStore`, connection string toggle, CI service + env, integration tests (POST→GET, second factory, unknown player), README/env docs. **Partially matches** — plan listed optional `appsettings.Development.json`; implementation correctly leaves connection string unset by default (still within plan). |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server remains authoritative for `PositionState`; persistence does not move truth to the client. |
|
||||
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches (post-review update)** — Snapshot describes optional Postgres persistence + NS-17; “Not yet” trimmed to prediction / full slice / telemetry. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches (post-review update)** — E1.M1 tracking row includes NS-17 and Postgres-when-configured. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M1 remains **In Progress**; no contract version promotion required for this slice. |
|
||||
| `docs/decomposition/modules/contracts.md` | **Matches** — HTTP JSON shapes unchanged; NS-17 is durability, not a breaking wire change. |
|
||||
| `server/README.md` | **Matches** — persistence, env vars, local Postgres test instructions. |
|
||||
|
||||
---
|
||||
|
||||
## Blocking issues
|
||||
|
||||
*None identified.*
|
||||
|
||||
---
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Docs alignment (recommended before merge):**~~ **Done** — `E1_M1_InputAndMovementRuntime.md` and `documentation_and_implementation_alignment.md` updated for NS-17 / optional Postgres (planned follow-up; separate from **Nit 1** below).
|
||||
2. ~~**Lifecycle:**~~ **Tracked:** [NS-22](https://neon-sprawl.atlassian.net/browse/NS-22) under epic [NS-20 — Tech Debt](https://neon-sprawl.atlassian.net/browse/NS-20) — dispose `NpgsqlDataSource` on host shutdown (was: singleton never disposed; acceptable for prototype until addressed).
|
||||
3. ~~**Naming clarity:**~~ **Done** — `EnsureSchema()` XML summary in `PostgresPositionStateStore.cs` now states the heavy path (read migration + DDL) runs at most once per process; later calls are the fast volatile-read path. No rename.
|
||||
|
||||
---
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~**`.vscode/`** appears as untracked in the working tree~~ — **Resolved (author):** no longer listed as untracked; do not commit editor-local `.vscode` unless the team opts in.
|
||||
- ~~**`EnsureDevPlayerRow` every call**~~ **Addressed:** dev row is seeded at PostgreSQL host startup (`PostgresDevPlayerSeedHostedService`); integration tests re-seed after `TRUNCATE` via `PostgresPositionBootstrap.SeedDevPlayer` + `IOptions<GamePositionOptions>` from the factory.
|
||||
- ~~**Unicode normalization:**~~ **Deferred / accepted:** team expects **ASCII** player ids; `ToLowerInvariant()` vs in-memory `StringComparer.OrdinalIgnoreCase` parity for full Unicode is **not** a priority unless requirements change.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Before merge, the author should run:
|
||||
|
||||
1. **No Postgres:** `dotnet test NeonSprawl.sln -c Release` — expect in-memory tests green; Postgres tests **skipped** if `ConnectionStrings__NeonSprawl` is unset.
|
||||
2. **With Postgres:** `docker compose up -d` from repo root, then
|
||||
`export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev'`
|
||||
and `dotnet test NeonSprawl.sln -c Release` — all tests including `PostgresPositionStateIntegrationTests` should pass.
|
||||
3. **Manual AC (optional):** `dotnet run` with connection string set, `POST …/move`, restart server, `GET …/position` unchanged.
|
||||
|
||||
Ensure **all** NS-17 files (including `server/db/` and new test types) are **staged and committed** — at review time much of the implementation was still untracked relative to `git status`.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
class-definitions-order:
|
||||
- tools
|
||||
- classnames
|
||||
- extends
|
||||
- docstrings
|
||||
- signals
|
||||
- enums
|
||||
- consts
|
||||
- staticvars
|
||||
- exports
|
||||
- pubvars
|
||||
- prvvars
|
||||
- onreadypubvars
|
||||
- onreadyprvvars
|
||||
- others
|
||||
class-load-variable-name: (([A-Z][a-z0-9]*)+|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
|
||||
class-name: ([A-Z][a-z0-9]*)+
|
||||
class-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
comparison-with-itself: null
|
||||
constant-name: _?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*
|
||||
disable: []
|
||||
duplicated-load: null
|
||||
enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*'
|
||||
enum-name: ([A-Z][a-z0-9]*)+
|
||||
excluded_directories: !!set
|
||||
.git: null
|
||||
expression-not-assigned: null
|
||||
function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
function-arguments-number: 10
|
||||
function-name: (_on_([A-Z][a-z0-9]*)+(_[a-z0-9]+)*|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
|
||||
function-preload-variable-name: ([A-Z][a-z0-9]*)+
|
||||
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
|
||||
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
|
||||
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
max-file-lines: 1000
|
||||
max-line-length: 100
|
||||
max-public-methods: 20
|
||||
max-returns: 6
|
||||
mixed-tabs-and-spaces: null
|
||||
no-elif-return: null
|
||||
no-else-return: null
|
||||
signal-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
|
||||
sub-class-name: _?([A-Z][a-z0-9]*)+
|
||||
tab-characters: 1
|
||||
trailing-whitespace: null
|
||||
unnecessary-pass: null
|
||||
unused-argument: null
|
||||
|
|
@ -346,6 +346,14 @@ Transform the validated prototype into a scalable production-ready development m
|
|||
- **Scope:** Metrics pipeline, tuning controls, event tooling, anti-cheat/anti-bot workflows, incident runbooks.
|
||||
- **Success criteria:** Team can operate, balance, and protect the game continuously post-launch with fast feedback loops.
|
||||
|
||||
### Tooling epic — Internal authoring and content pipeline (CT)
|
||||
|
||||
**Type:** Operator- and build-focused **tooling epic**, parallel to the numbered **E1–E9** player/product epics above. It does **not** extend the sequence to “Epic 10”; modules use the **`CT.M#`** prefix. Full program doc (slices, risks, DoD): [`docs/decomposition/tooling/internal_authoring.md`](docs/decomposition/tooling/internal_authoring.md).
|
||||
|
||||
- **Ownership focus:** Content Engineering + Tools + Build/CI
|
||||
- **Scope:** JSON (or YAML) schemas for catalogs, **CI and local validation** (including cross-table reference checks), optional **internal authoring UI** (TypeScript-friendly per tech stack), reference/rename workflows, optional content bundles; consumes contract *shapes* from **E2 / E3 / E4 / E7** as those stabilize.
|
||||
- **Success criteria:** Zones, items, recipes, and quests can be authored as **file-backed, versioned** data with merge gates that prevent broken references; no separate authoring database as source of truth.
|
||||
|
||||
## System Modules by Epic (Level 2)
|
||||
|
||||
Module template:
|
||||
|
|
@ -566,6 +574,26 @@ Module template:
|
|||
- Dependencies: E9.M1.
|
||||
- Stage target: Pre-production.
|
||||
|
||||
### Content tooling modules (CT) — Internal authoring
|
||||
|
||||
**Tooling track** (not E1–E9). Depends **softly** on catalog shapes from **E2.M1**, **E3.M2–M3**, **E4.M1**, **E7.M1**; see [`docs/decomposition/tooling/internal_authoring.md`](docs/decomposition/tooling/internal_authoring.md).
|
||||
|
||||
- **CT.M1 ContentValidationPipeline**
|
||||
- Responsibility: Canonical schemas for content files; CI and local validation; optional server/test smoke load of catalogs.
|
||||
- Key contracts: JSON Schema bundle, validation CLI, stable error format for editors.
|
||||
- Dependencies: Soft alignment with E2/E3/E4/E7 content contracts; can start with minimal schemas.
|
||||
- Stage target: Prototype.
|
||||
- **CT.M2 AuthoringStudioApplication**
|
||||
- Responsibility: Internal authoring app (e.g. Vite + React) for `content/**`, forms and ID pickers, zone graph editing aligned with **E4.M1** payloads.
|
||||
- Key contracts: Repo read/write paths; optional local API for dev browser; invokes **CT.M1** rules.
|
||||
- Dependencies: CT.M1.
|
||||
- Stage target: Prototype.
|
||||
- **CT.M3 ContentReferenceAndBundleWorkflows**
|
||||
- Responsibility: Project-wide reference index, safer renames, schema-version warnings; optional bundle manifest for client/server parity.
|
||||
- Key contracts: `ContentBundleManifest` (optional), reference graph for CI/review.
|
||||
- Dependencies: CT.M1; CT.M2 recommended for integrated UX.
|
||||
- Stage target: Pre-production.
|
||||
|
||||
## Module Dependency Flow (Level 2)
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ public class MoveCommandApiTests
|
|||
public async Task PostMove_ShouldPersistTargetAndIncrementSequence_WhenDevPlayerPostsValidMove()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var cmd = new MoveCommandRequest
|
||||
{
|
||||
|
|
@ -55,7 +54,7 @@ public class MoveCommandApiTests
|
|||
public async Task PostMove_ShouldReturnNotFound_WhenPlayerIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var cmd = new MoveCommandRequest
|
||||
{
|
||||
|
|
@ -74,7 +73,7 @@ public class MoveCommandApiTests
|
|||
public async Task PostMove_ShouldReturnBadRequest_WhenSchemaVersionIsWrong()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var cmd = new MoveCommandRequest
|
||||
{
|
||||
|
|
@ -93,7 +92,7 @@ public class MoveCommandApiTests
|
|||
public async Task PostMove_ShouldReturnBadRequest_WhenTargetIsMissing()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var content = new StringContent(
|
||||
"{\"schemaVersion\":1}",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
/// <summary>Integration tests for <see cref="NeonSprawl.Server.Game.PositionState.PositionStateApi"/> GET routes.</summary>
|
||||
public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
||||
: IClassFixture<WebApplicationFactory<Program>>
|
||||
public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
|
||||
: IClassFixture<InMemoryWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
private readonly HttpClient httpClient = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task GetPosition_ShouldReturnVersionedPayloadAtOrigin_WhenDevPlayerRequested()
|
||||
|
|
@ -19,7 +18,7 @@ public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
|||
const string url = "/game/players/dev-local-1/position";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(url);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
|
@ -40,7 +39,7 @@ public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
|||
const string url = "/game/players/DEV-LOCAL-1/position";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(url);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
|
@ -58,7 +57,7 @@ public class PositionStateApiTests(WebApplicationFactory<Program> factory)
|
|||
const string url = "/game/players/unknown-player/position";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(url);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
[CollectionDefinition("Postgres integration")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresWebApplicationFactory>
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
/// <summary>NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database.</summary>
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class PostgresPositionStateIntegrationTests(PostgresWebApplicationFactory factory)
|
||||
{
|
||||
[RequirePostgresFact]
|
||||
public async Task PostMove_ThenGet_ShouldReflectPersistedPositionAndSequence()
|
||||
{
|
||||
// Arrange
|
||||
await ResetPlayerPositionTableAsync();
|
||||
using var client = factory.CreateClient();
|
||||
var cmd = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 2, Y = -1, Z = 3.5 },
|
||||
};
|
||||
|
||||
// Act
|
||||
var before = await client.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
|
||||
var post = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
|
||||
var postBody = await post.Content.ReadFromJsonAsync<PositionStateResponse>();
|
||||
var after = await client.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(before);
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(postBody);
|
||||
Assert.NotNull(after);
|
||||
Assert.Equal(PositionStateResponse.CurrentSchemaVersion, postBody!.SchemaVersion);
|
||||
Assert.Equal(before!.Sequence + 1, postBody.Sequence);
|
||||
Assert.Equal(2, postBody.Position.X);
|
||||
Assert.Equal(-1, postBody.Position.Y);
|
||||
Assert.Equal(3.5, postBody.Position.Z);
|
||||
Assert.Equal(postBody.Sequence, after!.Sequence);
|
||||
Assert.Equal(postBody.Position.X, after.Position.X);
|
||||
Assert.Equal(postBody.Position.Y, after.Position.Y);
|
||||
Assert.Equal(postBody.Position.Z, after.Position.Z);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task SecondProcess_ShouldReadLastPosition_AfterFirstFactoryDisposed()
|
||||
{
|
||||
// Arrange
|
||||
await ResetPlayerPositionTableAsync();
|
||||
var cmd = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 10, Y = 20, Z = 30 },
|
||||
};
|
||||
|
||||
HttpStatusCode postStatus = default;
|
||||
PositionStateResponse? persisted = null;
|
||||
|
||||
// Act
|
||||
using (var first = factory.CreateClient())
|
||||
{
|
||||
var post = await first.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
|
||||
postStatus = post.StatusCode;
|
||||
persisted = await post.Content.ReadFromJsonAsync<PositionStateResponse>();
|
||||
}
|
||||
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondClient = secondFactory.CreateClient();
|
||||
var after = await secondClient.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, postStatus);
|
||||
Assert.NotNull(persisted);
|
||||
Assert.NotNull(after);
|
||||
Assert.Equal(10, after!.Position.X);
|
||||
Assert.Equal(20, after.Position.Y);
|
||||
Assert.Equal(30, after.Position.Z);
|
||||
Assert.Equal(1ul, after.Sequence);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task PostMove_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await ResetPlayerPositionTableAsync();
|
||||
using var client = factory.CreateClient();
|
||||
var cmd = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 0, Y = 0, Z = 0 },
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync("/game/players/not-a-seeded-player/move", cmd);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>DDL + empty table + dev row matching the test host's <see cref="GamePositionOptions"/> (parity with startup seed).</summary>
|
||||
private async Task ResetPlayerPositionTableAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
_ = factory.Services;
|
||||
var options = factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = await File.ReadAllTextAsync(ddlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var apply = new NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
await apply.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
/// <summary>Integration host with <see cref="PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName"/> bound from environment.</summary>
|
||||
public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md).");
|
||||
}
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:NeonSprawl"] = cs.Trim(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
/// <summary>Skips unless <c>ConnectionStrings__NeonSprawl</c> is set; fails in CI if missing so misconfigured workflows do not silently pass.</summary>
|
||||
public sealed class RequirePostgresFactAttribute : FactAttribute
|
||||
{
|
||||
public RequirePostgresFactAttribute()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
var inCi = string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.Ordinal);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCi)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CI must set ConnectionStrings__NeonSprawl for Postgres integration tests.");
|
||||
}
|
||||
|
||||
Skip = "Set ConnectionStrings__NeonSprawl to run Postgres integration tests (see server/README.md).";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using Npgsql;
|
||||
|
||||
namespace NeonSprawl.Server.Tests;
|
||||
|
||||
/// <summary>Forces the in-memory position store by replacing Postgres registrations after the host builds services (overrides process env e.g. CI <c>ConnectionStrings__NeonSprawl</c>).</summary>
|
||||
public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
for (var i = services.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var d = services[i];
|
||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
(d.ServiceType == typeof(IHostedService) &&
|
||||
d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService)))
|
||||
{
|
||||
services.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
|
|
@ -22,4 +23,8 @@
|
|||
<ProjectReference Include="..\NeonSprawl.Server\NeonSprawl.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\db\migrations\V001__player_position.sql" Link="db\migrations\V001__player_position.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>In-memory source of truth for player positions (NS-15 read API, NS-16 move apply).</summary>
|
||||
/// <summary>Authoritative player positions — in-memory or PostgreSQL per configuration (NS-15 read API, NS-16 move apply, NS-17 persistence).</summary>
|
||||
public interface IPositionStateStore
|
||||
{
|
||||
/// <summary>Returns false if the player is unknown (HTTP 404). <paramref name="playerId"/> is trimmed; matching is ordinal case-insensitive.</summary>
|
||||
|
|
|
|||
|
|
@ -4,31 +4,34 @@ using Microsoft.Extensions.Options;
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Thread-safe in-memory positions; seeds the configured dev player on construction.</summary>
|
||||
public sealed class InMemoryPositionStateStore : IPositionStateStore
|
||||
public sealed class InMemoryPositionStateStore(IOptions<GamePositionOptions> options) : IPositionStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PositionSnapshot> _positions = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, PositionSnapshot> positions = CreateInitialMap(options.Value);
|
||||
|
||||
public InMemoryPositionStateStore(IOptions<GamePositionOptions> options)
|
||||
private static ConcurrentDictionary<string, PositionSnapshot> CreateInitialMap(GamePositionOptions o)
|
||||
{
|
||||
var o = options.Value;
|
||||
var id = o.DevPlayerId.Trim();
|
||||
if (id.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
var map = new ConcurrentDictionary<string, PositionSnapshot>(StringComparer.OrdinalIgnoreCase);
|
||||
var p = o.DefaultPosition ?? new DefaultPositionOptions();
|
||||
_positions[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0);
|
||||
map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0);
|
||||
return map;
|
||||
}
|
||||
|
||||
public bool TryGetPosition(string playerId, out PositionSnapshot snapshot)
|
||||
{
|
||||
var key = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
return positions.TryGetValue(key, out snapshot);
|
||||
}
|
||||
|
||||
return _positions.TryGetValue(key, out snapshot);
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot)
|
||||
|
|
@ -42,18 +45,20 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore
|
|||
|
||||
while (true)
|
||||
{
|
||||
if (!_positions.TryGetValue(key, out var previous))
|
||||
if (!positions.TryGetValue(key, out var previous))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var next = new PositionSnapshot(x, y, z, previous.Sequence + 1);
|
||||
if (_positions.TryUpdate(key, next, previous))
|
||||
if (!positions.TryUpdate(key, next, previous))
|
||||
{
|
||||
snapshot = next;
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
snapshot = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ public static class PositionStateApi
|
|||
(string id, IPositionStateStore store) =>
|
||||
{
|
||||
if (!store.TryGetPosition(id, out var snap))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var body = new PositionStateResponse
|
||||
{
|
||||
|
|
@ -26,17 +28,21 @@ public static class PositionStateApi
|
|||
"/game/players/{id}/move",
|
||||
(string id, MoveCommandRequest? body, IPositionStateStore store) =>
|
||||
{
|
||||
if (body is null)
|
||||
return Results.BadRequest();
|
||||
if (body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion)
|
||||
if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var target = body.Target;
|
||||
if (target is null)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var response = new PositionStateResponse
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Registers in-memory position authority and options for NS-15.</summary>
|
||||
/// <summary>Registers position authority: PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set, otherwise in-memory (NS-15 / NS-17).</summary>
|
||||
public static class PositionStateServiceCollectionExtensions
|
||||
{
|
||||
private const string NeonSprawlConnectionStringName = "NeonSprawl";
|
||||
|
||||
public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<GamePositionOptions>(configuration.GetSection(GamePositionOptions.SectionName));
|
||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
|
||||
var cs = configuration.GetConnectionString(NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
var trimmed = cs.Trim();
|
||||
services.AddSingleton(_ => Npgsql.NpgsqlDataSource.Create(trimmed));
|
||||
services.AddHostedService<PostgresDevPlayerSeedHostedService>();
|
||||
services.AddSingleton<IPositionStateStore, PostgresPositionStateStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Runs DDL and dev-player seed once when the host starts (PostgreSQL path only).</summary>
|
||||
internal sealed class PostgresDevPlayerSeedHostedService(
|
||||
Npgsql.NpgsqlDataSource dataSource,
|
||||
IOptions<GamePositionOptions> positionOptions) : IHostedService
|
||||
{
|
||||
private readonly GamePositionOptions gameOptions = RequireDevPlayer(positionOptions.Value);
|
||||
|
||||
private static GamePositionOptions RequireDevPlayer(GamePositionOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.DevPlayerId))
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PostgresPositionBootstrap.EnsureSchema(dataSource);
|
||||
PostgresPositionBootstrap.SeedDevPlayer(dataSource, gameOptions);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>
|
||||
/// Applies NS-17 DDL (once per process, idempotent) and seeds the configured dev player row.
|
||||
/// Used at host startup and by integration tests after <c>TRUNCATE</c>.
|
||||
/// </summary>
|
||||
public static class PostgresPositionBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V001__player_position.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static int _schemaReady;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures <c>player_position</c> exists. Heavy work (read migration, run DDL) runs at most once per process.
|
||||
/// </summary>
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NS-17 DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using (var cmd = new Npgsql.NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
System.Threading.Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Idempotent insert for <see cref="GamePositionOptions.DevPlayerId"/> at default position.</summary>
|
||||
public static void SeedDevPlayer(Npgsql.NpgsqlDataSource dataSource, GamePositionOptions options)
|
||||
{
|
||||
using var conn = dataSource.OpenConnection();
|
||||
SeedDevPlayer(conn, options);
|
||||
}
|
||||
|
||||
/// <summary>Idempotent insert using an existing open connection (e.g. test harness after <c>TRUNCATE</c>).</summary>
|
||||
public static void SeedDevPlayer(Npgsql.NpgsqlConnection connection, GamePositionOptions options)
|
||||
{
|
||||
var devNorm = NormalizePlayerId(options.DevPlayerId);
|
||||
var p = options.DefaultPosition ?? new DefaultPositionOptions();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence)
|
||||
VALUES (@pid, @x, @y, @z, 0)
|
||||
ON CONFLICT (player_id) DO NOTHING;
|
||||
""",
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("pid", devNorm);
|
||||
cmd.Parameters.AddWithValue("x", p.X);
|
||||
cmd.Parameters.AddWithValue("y", p.Y);
|
||||
cmd.Parameters.AddWithValue("z", p.Z);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>PostgreSQL-backed <see cref="IPositionStateStore"/> (NS-17). Player ids are stored normalized: invariant lower + trim for case-insensitive parity with <see cref="InMemoryPositionStateStore"/>.</summary>
|
||||
public sealed class PostgresPositionStateStore(
|
||||
Npgsql.NpgsqlDataSource dataSource,
|
||||
IOptions<GamePositionOptions> positionOptions) : IPositionStateStore
|
||||
{
|
||||
private readonly GamePositionOptions gameOptions = RequireValidOptions(positionOptions.Value);
|
||||
|
||||
private static GamePositionOptions RequireValidOptions(GamePositionOptions options)
|
||||
{
|
||||
if (options.DevPlayerId.Trim().Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public bool TryGetPosition(string playerId, out PositionSnapshot snapshot)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.EnsureSchema(dataSource);
|
||||
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT pos_x, pos_y, pos_z, sequence FROM player_position WHERE player_id = @pid;",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = ReadSnapshot(reader);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.EnsureSchema(dataSource);
|
||||
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
UPDATE player_position
|
||||
SET pos_x = @x, pos_y = @y, pos_z = @z,
|
||||
sequence = sequence + 1,
|
||||
updated_at = now()
|
||||
WHERE player_id = @pid
|
||||
RETURNING pos_x, pos_y, pos_z, sequence;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
cmd.Parameters.AddWithValue("x", x);
|
||||
cmd.Parameters.AddWithValue("y", y);
|
||||
cmd.Parameters.AddWithValue("z", z);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = ReadSnapshot(reader);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PositionSnapshot ReadSnapshot(Npgsql.NpgsqlDataReader reader)
|
||||
{
|
||||
var seq = reader.GetInt64(3);
|
||||
return new PositionSnapshot(
|
||||
reader.GetDouble(0),
|
||||
reader.GetDouble(1),
|
||||
reader.GetDouble(2),
|
||||
unchecked((ulong)seq));
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,4 +14,16 @@
|
|||
<UseAppHost>false</UseAppHost>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="NeonSprawl.Server.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\db\migrations\*.sql" Link="db\migrations\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Game server (`NeonSprawl.Server`)
|
||||
|
||||
ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + in-memory position read API).
|
||||
ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + position state via in-memory **or** PostgreSQL).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -16,9 +16,28 @@ dotnet run
|
|||
- Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`).
|
||||
- Check `GET /health` for a JSON heartbeat.
|
||||
|
||||
## Position persistence (NS-17)
|
||||
|
||||
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).
|
||||
|
||||
If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NS-15/NS-16 (**in-memory** only). This keeps `dotnet test` without Postgres viable for fast checks; **override any process-level env** in tests when you need in-memory (see `InMemoryWebApplicationFactory` in the test project).
|
||||
|
||||
**Environment variables** (typical — same mapping as other ASP.NET Core config):
|
||||
|
||||
| Mechanism | Example |
|
||||
|-----------|---------|
|
||||
| Shell / CI | `export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev'` |
|
||||
| User secrets | `dotnet user-secrets set ConnectionStrings:NeonSprawl "<connection string>"` (run from `server/NeonSprawl.Server`) |
|
||||
|
||||
Match credentials with [root `docker-compose.yml`](../../docker-compose.yml) for local development.
|
||||
|
||||
**Restart check (Jira AC):** start Postgres (`docker compose up -d` from repo root), set the connection string, `dotnet run`, `POST …/move`, stop the server, `dotnet run` again, `GET …/position` should return the last committed state.
|
||||
|
||||
**Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml)). Locally: export the same value while Postgres is listening, then `dotnet test NeonSprawl.sln`.
|
||||
|
||||
## Position state (NS-15)
|
||||
|
||||
Authoritative player position is held **in memory** (no database yet). The HTTP JSON shape is versioned with top-level `schemaVersion` (see XML docs on `PositionStateResponse` in the server project). Path `{id}` is **trimmed** and matched **case-insensitively** against stored players; `playerId` in the JSON body echoes the path segment from the request.
|
||||
Authoritative player position is served over HTTP whether the backing store is **in memory** or **PostgreSQL** (NS-17). The HTTP JSON shape is versioned with top-level `schemaVersion` (see XML docs on `PositionStateResponse` in the server project). Path `{id}` is **trimmed** and matched **case-insensitively** against stored players; `playerId` in the JSON body echoes the path segment from the request. Stored player ids use **invariant lowercase + trim** in Postgres for lookup parity.
|
||||
|
||||
Example (dev player id defaults to `dev-local-1` in `appsettings.json`):
|
||||
|
||||
|
|
@ -32,7 +51,7 @@ Sample response:
|
|||
{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":0,"y":0,"z":0},"sequence":0}
|
||||
```
|
||||
|
||||
Unknown player ids return **404**. This endpoint is a **spike** until durable persistence and full sync exist.
|
||||
Unknown player ids return **404**. Full zone sync / replication is still out of scope for these spikes.
|
||||
|
||||
## Move command (NS-16)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
-- NS-17: authoritative player position (column layout; player_id is normalized in app: lower(trim(raw))).
|
||||
CREATE TABLE IF NOT EXISTS player_position (
|
||||
player_id TEXT PRIMARY KEY,
|
||||
pos_x DOUBLE PRECISION NOT NULL,
|
||||
pos_y DOUBLE PRECISION NOT NULL,
|
||||
pos_z DOUBLE PRECISION NOT NULL,
|
||||
sequence BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE player_position IS 'Persisted PositionSnapshot per player (NS-17).';
|
||||
Loading…
Reference in New Issue