NS-18: InteractionRequest, client prototype, Postgres test UX

Server: POST /game/players/{id}/interact, HorizontalReach, prototype registry, DTOs, tests; default spawn (-5,0.9,-5); dev HttpLogging.

Client: terminal, radius glow preview, interaction_request_client (interact/E), main wiring; obstacle moved off center.

Tests: postgres.runsettings, launchSettings, Docker compose auto-start/teardown harness, async-safe init.

Docs: plan AC, E1.M1 snapshot, alignment table, server/client README, code review 2026-04-04.
pull/20/head
VinPropane 2026-04-04 20:19:33 -04:00
parent 06f9ce8647
commit b043e8ac45
35 changed files with 1053 additions and 30 deletions

View File

@ -21,11 +21,24 @@ With the game server running ([`server/README.md`](../server/README.md)), the cl
1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`).
2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`).
3. Open the client in Godot and run the main scene (**F5**). The player should jump to the servers default position (e.g. origin).
3. Open the client in Godot and run the main scene (**F5**). The player should jump to the servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NS-18 walk demo).
4. **Left-click** the floor: the client POSTs the target, then GETs position; the avatar **snaps** to the authoritative coordinates (no client-side walk to the click).
If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes.
## Interaction + range preview (NS-18)
The main scene includes a **prototype terminal** at the map center (same world **X/Z** as the servers static registry) and **two glowing markers** driven by `scripts/interaction_radius_indicators.gd`. That script compares the **`CharacterBody3D`** **`global_position`** (after server snap) to the anchor in **`scripts/prototype_interaction_constants.gd`** using **horizontal distance on X/Z** and the same **`interaction_radius`** as **`PrototypeInteractableRegistry.cs`** — **preview only**; the server **`POST /game/players/{id}/interact`** is authoritative.
- **`InteractionRequestClient`** (child of the main scene root): press **E** to POST interact for the prototype id; **Output** prints `allowed` / `reasonCode` (or HTTP errors). While a request is in flight, further **E** presses are ignored (same pattern as **`PositionAuthorityClient`**).
- **Inspector:** match **`base_url`** / **`dev_player_id`** to **`PositionAuthorityClient`** and the server `Game:DevPlayerId`.
### Manual check (NS-18)
1. Run the game server and client as in NS-16.
2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **Click-move** toward the center until markers **brighten** (within **3** m on the floor plane).
3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere.
## Movement prototype (NS-14, legacy local steering)
The **`CharacterBody3D`** still uses horizontal steering + **`move_and_slide()`** in **`player.gd`**, but **NS-16** drives placement via **`snap_to_server()`** after each successful sync, so you will mostly see **snapping**, not walking to the click. Obstacle sliding from NS-14 applies only if local goals diverge from server snaps in future work.

View File

@ -25,6 +25,14 @@ config/icon="res://icon.svg"
window/size/viewport_width=1280
window/size/viewport_height=720
[input]
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
[dotnet]
project/assembly_name="Neon Sprawl"

View File

@ -4,6 +4,8 @@
[ext_resource type="Script" uid="uid://1jimgt3d4bjj" path="res://scripts/player.gd" id="2_player"]
[ext_resource type="Script" path="res://scripts/ground_pick.gd" id="3_ground"]
[ext_resource type="Script" path="res://scripts/position_authority_client.gd" id="4_auth"]
[ext_resource type="Script" path="res://scripts/interaction_request_client.gd" id="5_ix"]
[ext_resource type="Script" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(20, 0.2, 20)
@ -25,6 +27,18 @@ height = 1.0
radius = 0.4
height = 1.0
[sub_resource type="BoxMesh" id="BoxMesh_terminal"]
size = Vector3(0.9, 1, 0.4)
[sub_resource type="StandardMaterial3D" id="Mat_terminal"]
albedo_color = Color(0.28, 0.38, 0.45, 1)
[sub_resource type="BoxMesh" id="BoxMesh_marker"]
size = Vector3(0.22, 0.35, 0.22)
[sub_resource type="StandardMaterial3D" id="Mat_marker_base"]
albedo_color = Color(0.15, 0.4, 1, 1)
[node name="Main" type="Node3D" unique_id=1358372723]
script = ExtResource("1_main")
@ -50,7 +64,28 @@ mesh = SubResource("BoxMesh_floor")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
shape = SubResource("BoxShape3D_floor")
[node name="PrototypeTerminal" type="StaticBody3D" parent="World" unique_id=1700001]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/PrototypeTerminal" unique_id=1700002]
mesh = SubResource("BoxMesh_terminal")
surface_material_override/0 = SubResource("Mat_terminal")
[node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003]
script = ExtResource("6_rad")
[node name="MarkerA" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700004]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1, 0.25, 0.9)
mesh = SubResource("BoxMesh_marker")
surface_material_override/0 = SubResource("Mat_marker_base")
[node name="MarkerB" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700005]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05)
mesh = SubResource("BoxMesh_marker")
surface_material_override/0 = SubResource("Mat_marker_base")
[node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle" unique_id=750473628]
@ -78,3 +113,6 @@ script = ExtResource("3_ground")
[node name="PositionAuthorityClient" type="Node" parent="." unique_id=2500002]
script = ExtResource("4_auth")
[node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003]
script = ExtResource("5_ix")

View File

@ -0,0 +1,41 @@
extends Node3D
## NS-18: **Client preview only** — glow when `CharacterBody3D` is within horizontal radius of the
## prototype terminal (X/Z, inclusive). Server `POST …/interact` remains authoritative.
const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd")
@export var marker_paths: Array[NodePath] = [^"MarkerA", ^"MarkerB"]
var _player: CharacterBody3D
var _mat: StandardMaterial3D
const _emission_dim := 0.12
const _emission_bright := 7.0
func setup_player(player: CharacterBody3D) -> void:
_player = player
func _ready() -> void:
_mat = StandardMaterial3D.new()
_mat.albedo_color = Color(0.1, 0.32, 0.9)
_mat.emission_enabled = true
_mat.emission = Color(0.3, 0.6, 1.0)
_mat.emission_energy_multiplier = _emission_dim
for p in marker_paths:
var mi := get_node_or_null(p) as MeshInstance3D
if mi:
mi.material_override = _mat
func _process(_delta: float) -> void:
if _player == null or _mat == null:
return
var dx: float = _player.global_position.x - ProtoIx.anchor_x()
var dz: float = _player.global_position.z - ProtoIx.anchor_z()
var dist_sq: float = dx * dx + dz * dz
var r: float = ProtoIx.interaction_radius()
var in_range: bool = dist_sq <= r * r
_mat.emission_energy_multiplier = _emission_bright if in_range else _emission_dim

View File

@ -0,0 +1 @@
uid://n3p4f3fky3nv

View File

@ -0,0 +1,65 @@
extends Node
## NS-18: POST InteractionRequest; prints server allow/deny to Output (prototype).
const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd")
@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
func _ready() -> void:
_http = HTTPRequest.new()
add_child(_http)
_http.request_completed.connect(_on_request_completed)
func _input(event: InputEvent) -> void:
# _input (not _unhandled_input): unhandled often misses keys in the embedded Game view / focus quirks.
# Action `interact` is bound to E in project.godot; fallback keeps physical E for odd layouts.
if event.is_action_pressed("interact"):
_post_interact()
elif event is InputEventKey:
var k := event as InputEventKey
if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E):
_post_interact()
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _post_interact() -> void:
if _busy:
return
_busy = true
var url := "%s/game/players/%s/interact" % [_base_root(), dev_player_id.strip_edges()]
var payload := {"schemaVersion": 1, "interactableId": ProtoIx.interactable_id()}
var body := JSON.stringify(payload)
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
if err != OK:
push_warning("InteractionRequestClient: POST failed to start (%s)" % err)
_busy = false
func _on_request_completed(
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_busy = false
var text := body.get_string_from_utf8()
if response_code == 200:
var parsed: Variant = JSON.parse_string(text)
if parsed is Dictionary:
var d: Dictionary = parsed
var allowed: bool = bool(d.get("allowed", false))
var reason: Variant = d.get("reasonCode", null)
if allowed:
print("Interaction: allowed=true (reasonCode omitted on success)")
else:
print("Interaction: allowed=false reasonCode=%s" % str(reason))
return
print("Interaction: HTTP %s body=%s" % [response_code, text])

View File

@ -0,0 +1 @@
uid://bv0xprp660hib

View File

@ -1,12 +1,13 @@
extends Node3D
## NS-16: composes ground pick + server authority; see `ground_pick.gd` /
## `position_authority_client.gd`.
## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes).
@onready var _camera: Camera3D = $World/Camera3D
@onready var _player: CharacterBody3D = $Player
@onready var _ground_pick: Node3D = $GroundPick
@onready var _authority: Node = $PositionAuthorityClient
@onready var _radius_preview: Node3D = $World/InteractionMarkers
func _ready() -> void:
@ -16,6 +17,8 @@ func _ready() -> void:
"authoritative_position_received", Callable(self, "_on_authoritative_position")
)
_authority.call("sync_from_server")
if _radius_preview.has_method("setup_player"):
_radius_preview.call("setup_player", _player)
func _on_target_chosen(world: Vector3) -> void:

View File

@ -0,0 +1,23 @@
extends Object
## Prototype interactable anchor + reach — **must match**
## `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs`.
## Static accessors only (no `class_name`; see client README / NS-18 plan).
static func interactable_id() -> String:
return "prototype_terminal"
static func anchor_x() -> float:
return 0.0
static func anchor_y() -> float:
return 0.5
static func anchor_z() -> float:
return 0.0
static func interaction_radius() -> float:
return 3.0

View File

@ -0,0 +1 @@
uid://c8diye55s37rr

View File

@ -33,7 +33,7 @@ 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); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17).
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); 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/`). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). 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).

View File

@ -45,7 +45,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| Module | Register status | Snapshot | Plans / pointers |
|--------|-----------------|----------|-------------------|
| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client sync (NS-16). Prediction / full slice still open. | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), [NS-17](../../plans/NS-17-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`; [server README — Position / persistence](../../../server/README.md) |
| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client sync (NS-16); **InteractionRequest** + horizontal range (NS-18). 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), [NS-18](../../plans/NS-18-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
---

View File

@ -37,12 +37,12 @@ The game is **3D**, but **prototype interaction and movement tuning** treat all
## Acceptance criteria checklist
- [ ] **In range****200** + `allowed: true`, optional **`payload`**, **`reasonCode` omitted** (**exactly at** radius counts as in range — **inclusive** `<=`).
- [ ] **Out of range****200** + `allowed: false` and **required** **`reasonCode`** (e.g. `out_of_range`) — whenever **`allowed` is `false`**, **`reasonCode` must always be present** (non-empty). Document in DTO XML + README.
- [ ] **Moving** the player via existing **POST move → GET position** flow changes the outcome—**no** client-only bypass (server always re-reads store).
- [ ] **Client scene:** placeholder interactable **plus** **two or more small marker objects** that **glow** (e.g. **emission** / bright **modulate**) when the player is **within** **`interactionRadius`** on **X/Z** (inclusive `<=`), and appear **dim** when **out of range**—so you can **see** proximity while walking without a floor ring. Glow logic is **client preview only** (see §6); **`POST /interact`** remains authoritative.
- [ ] **Unit tests** for distance helper: **at** threshold, **just inside**, **just outside** (floating-point safe).
- [ ] **Integration:** unknown player → **404**; unknown interactable → **200** + `allowed: false`, `unknown_interactable`.
- [x] **In range****200** + `allowed: true`, optional **`payload`**, **`reasonCode` omitted** (**exactly at** radius counts as in range — **inclusive** `<=`).
- [x] **Out of range****200** + `allowed: false` and **required** **`reasonCode`** (e.g. `out_of_range`) — whenever **`allowed` is `false`**, **`reasonCode` must always be present** (non-empty). Document in DTO XML + README.
- [x] **Moving** the player via existing **POST move → GET position** flow changes the outcome—**no** client-only bypass (server always re-reads store).
- [x] **Client scene:** placeholder interactable **plus** **two or more small marker objects** that **glow** (e.g. **emission** / bright **modulate**) when the player is **within** **`interactionRadius`** on **X/Z** (inclusive `<=`), and appear **dim** when **out of range**—so you can **see** proximity while walking without a floor ring. Glow logic is **client preview only** (see §6); **`POST /interact`** remains authoritative.
- [x] **Unit tests** for distance helper: **at** threshold, **just inside**, **just outside** (floating-point safe).
- [x] **Integration:** unknown player → **404**; unknown interactable → **200** + `allowed: false`, `unknown_interactable`.
## Technical approach
@ -205,4 +205,8 @@ Authoritative values live in **`PrototypeInteractableRegistry.cs`**. **Godot** a
**Distance rule (v1):** Horizontal distance on **X/Z** only; **in range** iff `sqrt(dx² + dz²) <= interactionRadius` (**inclusive** boundary). **Y is not used** for reach (floor-level actions; multi-floor is a future change).
### Note for reviewers — `dotnet test` and Postgres
The test project uses committed **`postgres.runsettings`** (`RunSettingsFilePath` in `NeonSprawl.Server.Tests.csproj`), so **`ConnectionStrings__NeonSprawl` is always set** during `dotnet test` / Rider. The **three Postgres integration tests execute** (they do not skip). **Without** a reachable database—and **without** local Docker for the harness to run `docker compose up`—those tests **fail**. That is intentional; **CI** supplies Postgres via the workflow service container. See **`server/README.md`** (Postgres integration tests + **Contributors / PRs**).
Paste or adapt when opening the PR; align field names with final DTOs if they differ slightly.

View File

@ -0,0 +1,52 @@
# Code review — NS-18 (InteractionRequest + related)
**Date:** 2026-04-04
**Scope:** Branch `NS-18-interaction-request-range-check` and **current working tree** (large NS-18 implementation + Postgres test ergonomics + dev HTTP logging + Godot fixes are **modified/untracked** vs `origin/main`; only the plan file is committed ahead of main in this clone).
**Base:** `origin/main` (merge-base `a2015dd4bfe2e6554d677507499ed33e6a9e50b5` where available).
## Verdict
**Approve with nits** — Behavior matches NS-18 and E1.M1 intent; tests and docs are in good shape. **Commit and push the full implementation** before opening/merging the PR so review and CI run on the real diff.
## Summary
The work delivers **server-authoritative** `POST /game/players/{id}/interact` with **horizontal X/Z** range via `HorizontalReach`, a static **`PrototypeInteractableRegistry`**, versioned **InteractionRequest/InteractionResponse**, and **xUnit** unit + WebApplicationFactory integration coverage. The Godot side adds a **terminal**, **radius glow preview** (client-only), **`interaction_request_client.gd`** (E / `interact` action), and thin **`main.gd`** wiring—aligned with the plans “preview vs authority” split. Additional changes improve **local Postgres tests** (`postgres.runsettings`, optional `launchSettings.json`, **Docker Compose auto start/stop** with async-safe initialization to avoid Rider deadlocks), **dev-only HTTP logging**, **default spawn** `(-5, 0.9, -5)` for walk-to-terminal QA, and a small **scene** tweak (obstacle moved off center). Overall risk is **lowmoderate**: new HTTP surface is small and tested; Docker orchestration is local-only and skipped in CI.
## Documentation checked
| Document | Assessment |
|----------|------------|
| `docs/plans/NS-18-implementation-plan.md` | **Matches** — Endpoint, HTTP semantics, horizontal distance, registry defaults, DTO rules, client glow + POST, file list, and AC checklist align with the implementation. |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches** — Snapshot documents InteractionRequest + range check and links to NS-18 / server README. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E1.M1 row references NS-18 and server paths. |
| `docs/decomposition/modules/client_server_authority.md` (E1.M1) | **Matches** — Position-derived rules stay server-side; client glow is explicitly non-authoritative. |
| `server/README.md` | **Matches** — Interaction subsection, Postgres + `postgres.runsettings`, Rider xUnit note, Docker automation note, dev logging implied via Development config. |
| `client/README.md` | **Matches** — NS-16/NS-18 manual checks, `interact` / `_input` note. |
| `docs/decomposition/modules/module_dependency_register.md` | **Partially matches** — E1.M1 status remains “In Progress”; no conflict. Optional follow-up: bump or refine **register** row if you track NS-18 explicitly there (not required if table stays high-level). |
## Blocking issues
_None identified in the design or code paths reviewed._
**Process (merge gate):** Ensure **all** server/client/doc files for NS-18 and the test-harness/logging changes are **committed** on the branch so GitHub CI and reviewers see one coherent diff.
## Suggestions
1. **`HorizontalReach`:** ~~Consider guarding **`radius < 0`**~~ **Done**`ArgumentOutOfRangeException.ThrowIfNegative` + unit test.
2. **`InteractionRequestClient`:** ~~Optional **`_busy`** flag~~ **Done** — ignores **E** while HTTP in flight.
3. **Postgres + `postgres.runsettings`:** ~~Call out in PR description~~ **Done**`server/README.md` (**Contributors / PRs**) and `docs/plans/NS-18-implementation-plan.md` (reviewer note under PR draft).
## Nits
- **`interaction_request_client.gd`:** Both **`is_action_pressed("interact")`** and the **`KEY_E`** fallback could theoretically run for distinct events in the same frame if input mapping ever diverges; low priority.
- **`PostgresDockerHelper`:** `FindDockerComposeRoot` matches any `docker-compose.yml` containing `postgres:`—fine for this repo; could be overly broad in a monorepo layout (unlikely here).
## Verification
- `dotnet test NeonSprawl.sln` (with Postgres up or rely on auto-compose locally; CI provides Postgres).
- Godot **F5**: sync position, walk for glow, **E** → Output + dev server HTTP log shows **`POST …/interact`**.
- **Production-style run:** `ASPNETCORE_ENVIRONMENT=Production` — confirm **no** `UseHttpLogging` noise.
---
*Review produced per [code review agent](.cursor/rules/code-review-agent.md).*

View File

@ -0,0 +1,44 @@
using NeonSprawl.Server.Game.World;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
public class HorizontalReachTests
{
[Fact]
public void HorizontalDistance_IgnoresY()
{
// Y is not a parameter; identical X/Z → zero horizontal separation regardless of implied height difference.
var d = HorizontalReach.HorizontalDistance(1, 2, 1, 2);
Assert.Equal(0, d);
}
[Fact]
public void IsWithinHorizontalRadius_AtExactBoundary_ReturnsTrue()
{
Assert.True(HorizontalReach.IsWithinHorizontalRadius(0, 0, 3, 0, 3));
}
[Fact]
public void IsWithinHorizontalRadius_JustInside_ReturnsTrue()
{
const double radius = 3.0;
const double eps = 1e-10;
Assert.True(HorizontalReach.IsWithinHorizontalRadius(0, 0, radius - eps, 0, radius));
}
[Fact]
public void IsWithinHorizontalRadius_JustOutside_ReturnsFalse()
{
const double radius = 3.0;
const double eps = 1e-7;
Assert.False(HorizontalReach.IsWithinHorizontalRadius(0, 0, radius + eps, 0, radius));
}
[Fact]
public void IsWithinHorizontalRadius_NegativeRadius_ThrowsArgumentOutOfRange()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0));
}
}

View File

@ -0,0 +1,233 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
/// <summary>Integration tests for <see cref="InteractionApi"/> (NS-18).</summary>
public class InteractionApiTests
{
[Fact]
public async Task PostInteract_ShouldAllow_WhenExactlyAtHorizontalRadius()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 3, Y = 0, Z = 0 },
};
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact",
new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.True(body!.Allowed);
}
[Fact]
public async Task PostInteract_ShouldAllow_WhenPlayerInHorizontalRange()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 0, Y = 0.9, Z = 0 },
};
var moveResp = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
Assert.Equal(HttpStatusCode.OK, moveResp.StatusCode);
var req = new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.True(body!.Allowed);
Assert.Null(body.ReasonCode);
Assert.Equal(PrototypeInteractableRegistry.PrototypeTerminalId, body.InteractableId);
Assert.NotNull(body.Payload);
Assert.Equal("placeholder", body.Payload!.Kind);
}
[Fact]
public async Task PostInteract_ShouldDenyOutOfRange_WhenPlayerSeededFarFromTerminal()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var req = new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.False(body!.Allowed);
Assert.Equal(InteractionApi.ReasonOutOfRange, body.ReasonCode);
}
[Fact]
public async Task PostInteract_ShouldDenyUnknownInteractable_WhenIdNotInRegistry()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var req = new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = "no_such_thing",
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.False(body!.Allowed);
Assert.Equal(InteractionApi.ReasonUnknownInteractable, body.ReasonCode);
}
[Fact]
public async Task PostInteract_ShouldBeCaseInsensitive_ForInteractableId()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 0, Y = 0, Z = 0 },
};
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
var req = new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = " PROTOTYPE_TERMINAL ",
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.True(body!.Allowed);
}
[Fact]
public async Task PostInteract_ShouldReturnBadRequest_WhenSchemaVersionWrong()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var req = new InteractionRequest
{
SchemaVersion = 999,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostInteract_ShouldReturnBadRequest_WhenInteractableIdMissing()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var content = new StringContent(
$"{{\"schemaVersion\":{InteractionRequest.CurrentSchemaVersion}}}",
Encoding.UTF8,
"application/json");
var response = await client.PostAsync("/game/players/dev-local-1/interact", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostInteract_ShouldReturnBadRequest_WhenInteractableIdWhitespaceOnly()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var req = new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = " \t ",
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostInteract_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var req = new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
};
var response = await client.PostAsJsonAsync("/game/players/nobody-here/interact", req);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostInteract_ShouldReflectNewPosition_AfterMove()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var far = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact",
new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
});
var farBody = await far.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.False(farBody!.Allowed);
Assert.Equal(InteractionApi.ReasonOutOfRange, farBody.ReasonCode);
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 0, Y = 0, Z = 0 },
};
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
var near = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact",
new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
});
var nearBody = await near.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.True(nearBody!.Allowed);
}
}

View File

@ -27,9 +27,9 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
Assert.Equal(PositionStateResponse.CurrentSchemaVersion, body.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
Assert.NotNull(body.Position);
Assert.Equal(0, body.Position.X);
Assert.Equal(0, body.Position.Y);
Assert.Equal(0, body.Position.Z);
Assert.Equal(-5, body.Position.X);
Assert.Equal(0.9, body.Position.Y);
Assert.Equal(-5, body.Position.Z);
}
[Fact]
@ -47,7 +47,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
Assert.NotNull(body);
Assert.Equal(PositionStateResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("DEV-LOCAL-1", body.PlayerId);
Assert.Equal(0, body.Position!.X);
Assert.Equal(-5, body.Position!.X);
}
[Fact]

View File

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

View File

@ -0,0 +1,203 @@
using System.Diagnostics;
using Npgsql;
namespace NeonSprawl.Server.Tests.Game.PositionState;
/// <summary>
/// When not in CI, tries TCP to Postgres; if unreachable, runs <c>docker compose up -d</c> from the repo root.
/// Tracks whether compose was started so teardown can run <c>docker compose down</c>.
/// </summary>
/// <remarks>
/// All public entry points avoid <c>GetAwaiter().GetResult()</c> on the xUnit synchronization context;
/// <see cref="EnsurePostgresOrStartDockerBlocking"/> defers to the thread pool to prevent Rider/VS deadlocks.
/// </remarks>
internal static class PostgresDockerHelper
{
private static readonly object StateLock = new();
private static Task? _ensureTask;
private static bool _composeStartedByUs;
private static string? _repoRoot;
/// <summary>Async path for <see cref="PostgresIntegrationHarness.InitializeAsync"/> (no deadlock).</summary>
internal static Task EnsurePostgresOrStartDockerAsync(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return Task.CompletedTask;
}
lock (StateLock)
{
_ensureTask ??= EnsureCoreAsync(connectionString.Trim());
return _ensureTask;
}
}
/// <summary>
/// Used from <see cref="PostgresWebApplicationFactory.ConfigureWebHost"/> (sync only). Runs work on the thread pool
/// so <c>Npgsql</c> / <c>Task</c> continuations do not post back to a blocked xUnit context.
/// </summary>
internal static void EnsurePostgresOrStartDockerBlocking(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return;
}
Task.Run(() => EnsurePostgresOrStartDockerAsync(connectionString).GetAwaiter().GetResult())
.GetAwaiter()
.GetResult();
}
internal static async Task TearDownIfWeStartedAsync()
{
string? root;
bool weStarted;
lock (StateLock)
{
weStarted = _composeStartedByUs;
root = _repoRoot;
_composeStartedByUs = false;
_repoRoot = null;
_ensureTask = null;
}
if (!weStarted || string.IsNullOrEmpty(root))
{
return;
}
await RunDockerComposeAsync(root, "compose down", CancellationToken.None).ConfigureAwait(false);
}
private static async Task EnsureCoreAsync(string connectionString)
{
if (IsCiEnvironment())
{
await TryConnectWithTimeoutAsync(connectionString, TimeSpan.FromSeconds(30), CancellationToken.None)
.ConfigureAwait(false);
return;
}
if (await TryConnectOnceAsync(connectionString, CancellationToken.None).ConfigureAwait(false))
{
return;
}
var root = FindDockerComposeRoot();
if (root is null)
{
throw new InvalidOperationException(
"Postgres is not reachable and docker-compose.yml was not found by walking up from the test output directory.");
}
await RunDockerComposeAsync(root, "compose up -d", CancellationToken.None).ConfigureAwait(false);
await TryConnectWithTimeoutAsync(connectionString, TimeSpan.FromSeconds(60), CancellationToken.None)
.ConfigureAwait(false);
lock (StateLock)
{
_composeStartedByUs = true;
_repoRoot = root;
}
}
private static bool IsCiEnvironment() =>
string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Environment.GetEnvironmentVariable("GITLAB_CI"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "True", StringComparison.OrdinalIgnoreCase);
private static string? FindDockerComposeRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var yml = Path.Combine(dir.FullName, "docker-compose.yml");
if (File.Exists(yml))
{
try
{
var text = File.ReadAllText(yml);
if (text.Contains("neon-sprawl-postgres", StringComparison.Ordinal) ||
text.Contains("postgres:", StringComparison.OrdinalIgnoreCase))
{
return dir.FullName;
}
}
catch (IOException)
{
// keep walking
}
}
dir = dir.Parent;
}
return null;
}
private static async Task RunDockerComposeAsync(string workingDirectory, string arguments, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = "docker",
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = new Process { StartInfo = psi };
if (!proc.Start())
{
throw new InvalidOperationException("Failed to start `docker` — is Docker installed and on PATH?");
}
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
if (proc.ExitCode != 0)
{
var err = await proc.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
var @out = await proc.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
throw new InvalidOperationException(
$"`docker {arguments}` failed (exit {proc.ExitCode}). stderr: {err}. stdout: {@out}");
}
}
private static async Task TryConnectWithTimeoutAsync(
string connectionString,
TimeSpan overall,
CancellationToken ct)
{
var deadline = DateTime.UtcNow + overall;
while (DateTime.UtcNow < deadline)
{
if (await TryConnectOnceAsync(connectionString, ct).ConfigureAwait(false))
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false);
}
throw new InvalidOperationException(
$"Could not open a Postgres connection within {overall.TotalSeconds:0}s. Check the connection string and that the server is up.");
}
private static async Task<bool> TryConnectOnceAsync(string connectionString, CancellationToken ct)
{
try
{
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return true;
}
catch (NpgsqlException)
{
return false;
}
}
}

View File

@ -0,0 +1,25 @@
using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState;
/// <summary>
/// Collection fixture: ensures Postgres is reachable (starts Docker Compose from repo root when not in CI),
/// exposes the shared <see cref="PostgresWebApplicationFactory"/>, then disposes the factory and runs
/// <c>docker compose down</c> only if this harness started Compose.
/// </summary>
public sealed class PostgresIntegrationHarness : IAsyncLifetime
{
public PostgresWebApplicationFactory Factory { get; } = new();
public async Task InitializeAsync()
{
await PostgresDockerHelper.EnsurePostgresOrStartDockerAsync(
Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl")).ConfigureAwait(false);
}
public async Task DisposeAsync()
{
await Factory.DisposeAsync().ConfigureAwait(false);
await PostgresDockerHelper.TearDownIfWeStartedAsync().ConfigureAwait(false);
}
}

View File

@ -10,14 +10,15 @@ 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)
public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
[RequirePostgresFact]
public async Task PostMove_ThenGet_ShouldReflectPersistedPositionAndSequence()
{
// Arrange
await ResetPlayerPositionTableAsync();
using var client = factory.CreateClient();
using var client = Factory.CreateClient();
var cmd = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
@ -61,7 +62,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresWebApplication
PositionStateResponse? persisted = null;
// Act
using (var first = factory.CreateClient())
using (var first = Factory.CreateClient())
{
var post = await first.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
postStatus = post.StatusCode;
@ -87,7 +88,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresWebApplication
{
// Arrange
await ResetPlayerPositionTableAsync();
using var client = factory.CreateClient();
using var client = Factory.CreateClient();
var cmd = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
@ -110,8 +111,8 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresWebApplication
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = factory.Services;
var options = factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
_ = 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))

View File

@ -10,6 +10,7 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
PostgresDockerHelper.EnsurePostgresOrStartDockerBlocking(cs);
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException(

View File

@ -6,6 +6,8 @@
<Nullable>enable</Nullable>
<RootNamespace>NeonSprawl.Server.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<!-- Rider / dotnet test: env vars for [RequirePostgresFact] (see postgres.runsettings). -->
<RunSettingsFilePath>$(MSBuildProjectDirectory)\postgres.runsettings</RunSettingsFilePath>
</PropertyGroup>
<ItemGroup>

View File

@ -0,0 +1,11 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"NeonSprawl.Server.Tests": {
"commandName": "Project",
"environmentVariables": {
"ConnectionStrings__NeonSprawl": "Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev"
}
}
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- VSTest / Rider: supplies ConnectionStrings__NeonSprawl so Postgres integration tests run (not skipped). -->
<RunSettings>
<RunConfiguration>
<EnvironmentVariables>
<ConnectionStrings__NeonSprawl>Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev</ConnectionStrings__NeonSprawl>
</EnvironmentVariables>
</RunConfiguration>
</RunSettings>

View File

@ -0,0 +1,81 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>Maps <c>POST /game/players/{{id}}/interact</c> (NS-18).</summary>
public static class InteractionApi
{
public const string ReasonOutOfRange = "out_of_range";
public const string ReasonUnknownInteractable = "unknown_interactable";
public static WebApplication MapInteractionApi(this WebApplication app)
{
app.MapPost(
"/game/players/{id}/interact",
(string id, InteractionRequest? body, IPositionStateStore store) =>
{
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var rawId = body.InteractableId;
if (rawId is null)
{
return Results.BadRequest();
}
var interactableKey = rawId.Trim();
if (interactableKey.Length == 0)
{
return Results.BadRequest();
}
var lookupKey = interactableKey.ToLowerInvariant();
var playerKey = id.Trim();
if (!store.TryGetPosition(playerKey, out var snap))
{
return Results.NotFound();
}
if (!PrototypeInteractableRegistry.TryGet(lookupKey, out var entry))
{
return Results.Json(
new InteractionResponse
{
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
Allowed = false,
ReasonCode = ReasonUnknownInteractable,
});
}
if (!HorizontalReach.IsWithinHorizontalRadius(
snap.X,
snap.Z,
entry.X,
entry.Z,
entry.InteractionRadius))
{
return Results.Json(
new InteractionResponse
{
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
Allowed = false,
ReasonCode = ReasonOutOfRange,
});
}
return Results.Json(
new InteractionResponse
{
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
Allowed = true,
InteractableId = lookupKey,
Payload = new InteractionPlaceholderPayload(),
});
});
return app;
}
}

View File

@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>POST body for <c>POST /game/players/{{id}}/interact</c> (NS-18).</summary>
/// <remarks>
/// <c>interactableId</c> is trimmed; empty after trim → HTTP 400. Lookup is case-insensitive against lowercase registry keys.
/// </remarks>
public sealed class InteractionRequest
{
public const int CurrentSchemaVersion = 1;
/// <summary>Must be <see cref="CurrentSchemaVersion"/> for v1.</summary>
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
/// <summary>Registry key after trim + case-insensitive match.</summary>
[JsonPropertyName("interactableId")]
public string? InteractableId { get; init; }
}

View File

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>JSON body for every handled interaction attempt (HTTP 200 only; NS-18).</summary>
/// <remarks>
/// When <see cref="Allowed"/> is <c>false</c>, <see cref="ReasonCode"/> is required and must be non-empty.
/// When <see cref="Allowed"/> is <c>true</c>, omit <see cref="ReasonCode"/>; <see cref="Payload"/> is optional.
/// Unknown player → HTTP 404 (no body). Malformed v1 request → HTTP 400.
/// </remarks>
public sealed class InteractionResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("allowed")]
public bool Allowed { get; init; }
/// <summary>Stable machine string when denied; omitted when allowed.</summary>
[JsonPropertyName("reasonCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasonCode { get; init; }
/// <summary>Echo canonical id on success; omitted when denied.</summary>
[JsonPropertyName("interactableId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? InteractableId { get; init; }
[JsonPropertyName("payload")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public InteractionPlaceholderPayload? Payload { get; init; }
}
/// <summary>Placeholder success payload for prototype wiring.</summary>
public sealed class InteractionPlaceholderPayload
{
[JsonPropertyName("kind")]
public string Kind { get; init; } = "placeholder";
}

View File

@ -0,0 +1,22 @@
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>Prototype id → world anchor + reach. Keys are lowercase; lookup after trim + case fold (NS-18).</summary>
public static class PrototypeInteractableRegistry
{
/// <summary>Canonical lowercase id for the single prototype row.</summary>
public const string PrototypeTerminalId = "prototype_terminal";
/// <summary>Source-of-truth anchor and radius; keep in sync with <c>client/scripts/prototype_interaction_constants.gd</c>.</summary>
public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0);
private static readonly Dictionary<string, PrototypeInteractableEntry> ById = new(StringComparer.Ordinal)
{
[PrototypeTerminalId] = PrototypeTerminal,
};
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>
ById.TryGetValue(interactableIdLowercase, out entry);
}
/// <param name="InteractionRadius">Horizontal reach on X/Z; inclusive boundary.</param>
public readonly record struct PrototypeInteractableEntry(double X, double Y, double Z, double InteractionRadius);

View File

@ -7,7 +7,7 @@ namespace NeonSprawl.Server.Game.PositionState;
/// <remarks>
/// Example v1 payload (initial seed; <see cref="Sequence"/> increases after each successful move, NS-16):
/// <code>
/// {"schemaVersion":1,"playerId":"dev-local-1","position":{"x":0,"y":0,"z":0},"sequence":0}
/// {"schemaVersion":1,"playerId":"dev-local-1","position":{"x":-5,"y":0.9,"z":-5},"sequence":0}
/// </code>
/// </remarks>
public sealed class PositionStateResponse

View File

@ -0,0 +1,23 @@
namespace NeonSprawl.Server.Game.World;
/// <summary>
/// Horizontal (floor-plane) distance on <strong>X/Z only</strong>; <c>Y</c> is ignored for reach (NS-18).
/// </summary>
public static class HorizontalReach
{
/// <summary>Planar distance <c>sqrt(dx² + dz²)</c> using only <c>X</c> and <c>Z</c>.</summary>
public static double HorizontalDistance(double ax, double az, double bx, double bz)
{
var dx = ax - bx;
var dz = az - bz;
return Math.Sqrt(dx * dx + dz * dz);
}
/// <summary><c>true</c> when <see cref="HorizontalDistance"/> <c>&lt;= radius</c> (boundary inclusive).</summary>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="radius"/> is negative (invalid configuration).</exception>
public static bool IsWithinHorizontalRadius(double ax, double az, double bx, double bz, double radius)
{
ArgumentOutOfRangeException.ThrowIfNegative(radius);
return HorizontalDistance(ax, az, bx, bz) <= radius;
}
}

View File

@ -1,10 +1,28 @@
using Microsoft.AspNetCore.HttpLogging;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration);
if (builder.Environment.IsDevelopment())
{
builder.Services.AddHttpLogging(o =>
{
o.LoggingFields = HttpLoggingFields.RequestMethod
| HttpLoggingFields.RequestPath
| HttpLoggingFields.ResponseStatusCode
| HttpLoggingFields.Duration;
});
}
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseHttpLogging();
}
app.MapGet("/", () => Results.Text(
"Neon Sprawl game server — GET /health for JSON status.",
"text/plain"));
@ -16,5 +34,6 @@ app.MapGet("/health", () => Results.Json(new
}));
app.MapPositionStateApi();
app.MapInteractionApi();
app.Run();

View File

@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
}
}

View File

@ -9,9 +9,9 @@
"Game": {
"DevPlayerId": "dev-local-1",
"DefaultPosition": {
"X": 0,
"Y": 0,
"Z": 0
"X": -5,
"Y": 0.9,
"Z": -5
}
}
}

View File

@ -20,7 +20,7 @@ dotnet run
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x``sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory stores constructor seed (not on every HTTP request).
If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NS-15/NS-16 (**in-memory** only). This keeps `dotnet test` without Postgres viable for fast checks; **override any process-level env** in tests when you need in-memory (see `InMemoryWebApplicationFactory` in the test project).
If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NS-15/NS-16 (**in-memory** only). The **test** projects `postgres.runsettings` sets `ConnectionStrings__NeonSprawl` for the test host so Postgres integration tests run in CI/Rider/`dotnet test`; non-Postgres tests use `InMemoryWebApplicationFactory` and do not require a live DB. Remove or rename `postgres.runsettings` (and the `RunSettingsFilePath` property) if you want those three tests to **skip** again when the variable is unset.
**Environment variables** (typical — same mapping as other ASP.NET Core config):
@ -33,7 +33,13 @@ Match credentials with [root `docker-compose.yml`](../../docker-compose.yml) for
**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`.
**Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml)). The test project sets this via **`NeonSprawl.Server.Tests/postgres.runsettings`** (`RunSettingsFilePath` in the `.csproj`) so **`dotnet test`** and **Rider** pick it up without shell `export`. **`launchSettings.json`** in the same project is for IDE launch profiles and is **not** the xUnit “config file” in Rider settings.
**Contributors / PRs:** With **`postgres.runsettings`** checked in, **`dotnet test`** always injects that connection string, so the **three Postgres integration tests run** (they are **not** skipped). If nothing is listening on Postgres and **Docker** cannot start Compose (or you have no DB), those tests **fail**—that is expected, not a random flake. **Mitigations:** `docker compose up -d` from the repo root (tests may auto-start Compose locally when not in CI), or temporarily remove **`RunSettingsFilePath`** from `NeonSprawl.Server.Tests.csproj` while hacking. **GitHub Actions** provides Postgres in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml), so merges stay green when the workflow runs.
**Rider:** Under **Settings → … → Unit Testing → xUnit.net**, leave **“Use specific configuration file”** off unless you add a real **`xunit.runner.json`**. Do **not** point that field at `launchSettings.json`.
**Local Docker (optional automation):** When **not** in CI (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `TF_BUILD`), the Postgres test harness tries to connect first. If the server is unreachable, it runs **`docker compose up -d`** from the repo root (same compose file as [root `docker-compose.yml`](../../docker-compose.yml)), waits for the DB, runs tests, then runs **`docker compose down`** **only if** it started Compose itself. If Postgres was already listening (existing container or native install), tests do **not** stop your database afterward. Initialization is **async** (no sync-over-async on the xUnit sync context) so **Rider** / **Visual Studio** do not leave Postgres tests stuck **Pending** after Compose starts.
## Position state (NS-15)
@ -45,10 +51,10 @@ Example (dev player id defaults to `dev-local-1` in `appsettings.json`):
curl -s http://localhost:5253/game/players/dev-local-1/position
```
Sample response:
Sample response (default spawn matches Godot capsule and NS-18 walk-in range demo):
```json
{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":0,"y":0,"z":0},"sequence":0}
{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":-5,"y":0.9,"z":-5},"sequence":0}
```
Unknown player ids return **404**. Full zone sync / replication is still out of scope for these spikes.
@ -73,6 +79,37 @@ See XML on `MoveCommandRequest` and `PositionStateResponse` in the server projec
For a **PR/Jira-ready** contract blurb (formatted JSON + field table), see the [NS-15 implementation plan — Pull request description](../../docs/plans/NS-15-implementation-plan.md#pull-request-description-jira-ac) (GET shape) and [NS-16 implementation plan](../../docs/plans/NS-16-implementation-plan.md#pull-request-description-jira-ac--draft-for-merge) (MoveCommand + sequence semantics).
## Interaction (NS-18)
**`POST /game/players/{id}/interact`** with a versioned **InteractionRequest** body asks whether the given player may use a prototype interactable **now**. The server reads **authoritative** `PositionState` from `IPositionStateStore` (same id rules as position APIs: trim + ordinal case-insensitive match). **Horizontal reach** uses **X and Z only**; **Y is ignored** for distance (floor-plane prototype). See `HorizontalReach` in `NeonSprawl.Server/Game/World/` and `PrototypeInteractableRegistry` in `Game/Interaction/`.
Request (example):
```bash
curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"interactableId":"prototype_terminal"}'
```
**HTTP**
| Status | Meaning |
|--------|---------|
| **200** | Body is **InteractionResponse** v1: `allowed: true` (optional `payload`, `interactableId` echo) or `allowed: false` with required **`reasonCode`**. |
| **400** | Not a valid v1 attempt (bad/missing `schemaVersion`, malformed JSON, missing **`interactableId`**, or empty after trim). |
| **404** | Unknown **player** only (no row in the position store). |
**`reasonCode` (v1, when `allowed` is false)**
| Code | When |
|------|------|
| `out_of_range` | Horizontal distance on X/Z is **greater than** the interactables `interactionRadius` (on the radius is **allowed** — inclusive `<=`). |
| `unknown_interactable` | Id not in the prototype registry (after trim + case-insensitive lookup). |
When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**.
Contract details and PR blurb: [NS-18 implementation plan](../../docs/plans/NS-18-implementation-plan.md).
## Solution
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.