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
parent
06f9ce8647
commit
b043e8ac45
|
|
@ -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 server’s default position (e.g. origin).
|
||||
3. Open the client in Godot and run the main scene (**F5**). The player should jump to the server’s 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 server’s 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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://n3p4f3fky3nv
|
||||
|
|
@ -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])
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bv0xprp660hib
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c8diye55s37rr
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 plan’s “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 **low–moderate**: 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).*
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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><= 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
"DefaultPosition": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
"X": -5,
|
||||
"Y": 0.9,
|
||||
"Z": -5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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).
|
||||
If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NS-15/NS-16 (**in-memory** only). The **test** project’s `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 interactable’s `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`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue