Merge pull request #20 from ViPro-Technologies/NS-18-interaction-request-range-check
NS-18 interaction request range checkpull/22/head
commit
39dccef39f
|
|
@ -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,40 @@
|
|||
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")
|
||||
const EMISSION_DIM := 0.12
|
||||
const EMISSION_BRIGHT := 7.0
|
||||
|
||||
@export var marker_paths: Array[NodePath] = [^"MarkerA", ^"MarkerB"]
|
||||
|
||||
var _player: CharacterBody3D
|
||||
var _mat: StandardMaterial3D
|
||||
|
||||
|
||||
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:
|
||||
# Use _input: _unhandled_input often misses keys in the embedded Game dock / focus quirks.
|
||||
# `interact` is in project.godot; KEY_E fallback 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,25 @@
|
|||
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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
# NS-18 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NS-18 |
|
||||
| **Title** | E1.M1: InteractionRequest with server-side range check |
|
||||
| **Jira** | [NS-18](https://neon-sprawl.atlassian.net/browse/NS-18) |
|
||||
| **Parent context** | [NS-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NS-1) · [NS-10 — E1.M1 InputAndMovementRuntime](https://neon-sprawl.atlassian.net/browse/NS-10) |
|
||||
| **Decomposition** | [E1.M1 — InputAndMovementRuntime](../decomposition/modules/E1_M1_InputAndMovementRuntime.md) (range / authority); seeds [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) contracts |
|
||||
|
||||
**Delivery:** Ship **`docs/plans/NS-18-implementation-plan.md`** on the **same branch / PR** as the NS-18 implementation (plan + code pushed together).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** **Server-authoritative** answer to “can this player use this interactable **now**?” using the same **position source of truth** as movement ([NS-15](NS-15-implementation-plan.md) / [NS-16](NS-16-implementation-plan.md) / [NS-17](NS-17-implementation-plan.md)).
|
||||
|
||||
**Design decision — floor plane only (Y ignored for reach)**
|
||||
|
||||
The game is **3D**, but **prototype interaction and movement tuning** treat all meaningful play as **on the floor**. **Reach / range checks use X and Z only; Y is ignored** for distance (positions may still carry Y for rendering and future use). There is **no** vertical stacking, mezzanine, or multi-floor requirement in this story. **When** we add **multiple floors** or vertical gameplay, we will **revisit** distance rules (e.g. per-floor partition, vertical tolerance, or full 3D radius) and update contracts—**out of scope until then**.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **Shared distance helper** (server library): single place for **horizontal** reach on **X/Z** only (**Y not used** for threshold), documented for reuse. Matches `player.gd` horizontal steering and keeps server logic aligned with “actions on the floor.”
|
||||
- **Static prototype interactable table** in code (id → world position + **max interaction radius**). One row is enough (e.g. `prototype_terminal` near the play space).
|
||||
- **HTTP API:** versioned **`InteractionRequest`** JSON; server loads **authoritative** `PositionState` for `{id}` from `IPositionStateStore`, compares to interactable position via the helper, returns **allowed** + optional small payload, or **denied** + **stable `reasonCode`** string.
|
||||
- **Godot:** one **placeholder** interactable in the world (visible mesh + collision optional); minimal **client** path that POSTs an interaction for a **fixed id** after a key press (e.g. **E**) so QA can stand in range vs out of range without building full targeting.
|
||||
|
||||
**Out of scope (per Jira)**
|
||||
|
||||
- Inventory, rich UI prompts, full **E1.M3** selection / hover / target-lock polish, multiple interactable **types** or data-driven defs beyond the static table.
|
||||
|
||||
**Dependencies**
|
||||
|
||||
- Authoritative **`PositionState`** + **move** path ([NS-15](NS-15-implementation-plan.md), [NS-16](NS-16-implementation-plan.md)); optional Postgres ([NS-17](NS-17-implementation-plan.md)) unchanged—interaction reads current store only.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [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
|
||||
|
||||
### 1. Distance helper (C#)
|
||||
|
||||
- New static or injectable type, e.g. `WorldReach` or `InteractionRangeMath`, in a small folder under `NeonSprawl.Server/Game/` (e.g. `Game/World/` or next to interaction feature).
|
||||
- **`HorizontalDistance(a, b)`** → `double` using only **X** and **Z** (**Y intentionally ignored** per design decision above).
|
||||
- **`IsWithinHorizontalRadius(player, target, radius)`** → `bool` — **inclusive boundary:** `distance <= radius` counts as **in range** (exactly on the radius is allowed). Deny only when **`distance > radius`**.
|
||||
|
||||
### 2. Prototype interactable registry
|
||||
|
||||
- Static read-only map: **`interactableId`** (string, **lowercase** canonical keys) → **position** (`x,y,z`) + **`interactionRadius`** (double).
|
||||
|
||||
**Prototype defaults (source of truth: C# registry)**
|
||||
|
||||
Authoritative values live in **`PrototypeInteractableRegistry.cs`**. **Godot** and any client preview script **must match** (duplicate constants in `prototype_interaction_constants.gd` with a comment pointing at that file). Tune by editing **C# first**, then syncing the client.
|
||||
|
||||
| | Value | Rationale |
|
||||
|---|--------|-----------|
|
||||
| **`interactableId`** | `prototype_terminal` | Single prototype row. |
|
||||
| **World position** | **`x = 0`**, **`y = 0.5`**, **`z = 0`** | Map center on **X/Z** (floor ±10); **Y** ~mid prop above floor. Default spawn is **`(-5, 0.9, -5)`** — horizontal distance to **`(0, 0)`** is **√50 ≈ 7.07**, so a short walk tests **out of range → in range**. |
|
||||
| **`interactionRadius`** | **`3.0`** | ~3 m reach on the floor plane; easy to see glow / **E** flip without covering the whole arena. |
|
||||
|
||||
**`interactableId` matching (locked — default)**
|
||||
|
||||
- **Trim** leading/trailing whitespace on the request value.
|
||||
- **Case-insensitive** lookup: compare using **ordinal** ignore-case (or normalize to lowercase before dictionary lookup); registry keys remain **lowercase**.
|
||||
- **Missing** `interactableId`, wrong type, or **empty after trim** → **400** (not `unknown_interactable`).
|
||||
- After trim + case fold, if no registry entry → **200** + `allowed: false`, `reasonCode: unknown_interactable`.
|
||||
|
||||
### 3. Request / response DTOs (JSON v1)
|
||||
|
||||
- **`InteractionRequest`** (POST body): `schemaVersion` (`1`), `interactableId` (string, required).
|
||||
- **`InteractionResponse`** (**200** body): `schemaVersion` (`1`), `allowed` (bool). **Field rules (locked):**
|
||||
- **`allowed == false`:** **`reasonCode` is required** — non-empty stable string (`unknown_interactable`, `out_of_range`, …). Never return a denial without it.
|
||||
- **`allowed == true`:** **`reasonCode` omitted** (or explicitly documented as unused on success—clients must not require it). **`payload`** optional.
|
||||
- **Do not** use `reasonCode` for unknown player—that case is **404**, not a JSON denial body.
|
||||
- **Route:** `POST /game/players/{id}/interact` — player id in path (trim + case-insensitive match **consistent** with position APIs).
|
||||
|
||||
**HTTP status (locked)**
|
||||
|
||||
- **400** — Request not processed as a valid v1 interaction attempt (missing/invalid `schemaVersion`, malformed JSON, missing **`interactableId`**, **`interactableId` empty after trim**, etc.).
|
||||
- **404** — **Unknown player only** (no row in `IPositionStateStore` after id normalization)—same spirit as `GET` / `POST` position APIs.
|
||||
- **200** — Request **accepted and handled** without throwing: body is always **`InteractionResponse` v1**, including **`allowed: false`** + `reasonCode` for **unknown interactable**, **out of range**, or any other **logical** denial. No **403** for “denied” outcomes in v1.
|
||||
|
||||
### 4. Handler flow
|
||||
|
||||
1. Validate JSON body and **`schemaVersion`**; require **`interactableId`** present and **non-empty after trim** → else **400**.
|
||||
2. Normalize **`interactableId`** for lookup (trim + case-insensitive match against **lowercase** registry keys).
|
||||
3. Normalize player id; **`TryGetPosition`** from `IPositionStateStore`. If missing → **404** (end; no interaction body).
|
||||
4. Resolve interactable from static table; if missing → **200** + `allowed: false`, `reasonCode: unknown_interactable`.
|
||||
5. Else compute horizontal distance; if **`> radius`** (boundary **inclusive** `<=` is in range) → **200** + `allowed: false`, `reasonCode: out_of_range`.
|
||||
6. Else → **200** + `allowed: true` + optional placeholder payload.
|
||||
|
||||
### 5. Composition
|
||||
|
||||
- New `InteractionApi.MapInteractionApi(this WebApplication app)` (or merge into a thin `GameApi` later); **`Program.cs`** adds one line.
|
||||
- Inject or pass **`IPositionStateStore`** + registry (static for prototype).
|
||||
|
||||
### 6. Godot client (minimal)
|
||||
|
||||
- Add a **visible** placeholder for the interactable (e.g. **`StaticBody3D`** + mesh, or **`MeshInstance3D`**) at the **same** **X/Z** (and floor **Y**) as the server registry row (document in `client/README.md`).
|
||||
- **Radius QA — glowing markers (required for manual test):** Add **two or more** decorative meshes (small boxes, orbs, pylons—**no collision** needed) near the interactable so they read clearly in the isometric view. A small script (e.g. **`interaction_radius_indicators.gd`**) each frame (or when the player moves) compares the **authoritative** player position — the **`CharacterBody3D`** **`global_position`** after **server snap** — to the interactable anchor in **X/Z** only, using the **same** **`interactionRadius`** and **inclusive `<=`** rule as the server. When **in range**, bump **StandardMaterial3D.emission** (or **unshaded + modulate**) on those meshes to **glow**; when **out of range**, dim them. **This is preview-only** for human testing; **`POST /interact`** still decides allow/deny. **Document in `client/README.md`:** glow uses **`prototype_interaction_constants.gd`** — same numbers as **§2 prototype defaults** / **`PrototypeInteractableRegistry.cs`** (see table above).
|
||||
- New small script (e.g. `interaction_request_client.gd`) or extend `position_authority_client.gd`: on **key** (e.g. `ui_accept` / **E**), `POST` `/game/players/{dev_player_id}/interact` with fixed `interactableId`; print **`allowed` / `reasonCode`** to **Output** for prototype verification (no HUD required).
|
||||
- **`main.gd`**: wire exports/node paths for player, interactable anchor, indicator script, interaction client; keep **thin** composer rule ([godot-client-script-organization.md](../../.cursor/rules/godot-client-script-organization.md)).
|
||||
|
||||
### 7. Documentation
|
||||
|
||||
- **`server/README.md`:** subsection **Interaction (NS-18)** — endpoint, curl example, `reasonCode` table, note on horizontal distance.
|
||||
- **`docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md`** (and optionally **`documentation_and_implementation_alignment.md`**): one-line snapshot that **InteractionRequest** + range check landed (NS-18).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Interaction/InteractionRequest.cs` | Versioned POST body DTO + XML. |
|
||||
| `server/NeonSprawl.Server/Game/Interaction/InteractionResponse.cs` | Versioned response DTO; XML: **`reasonCode` required when `allowed` is false**; omit on success. |
|
||||
| `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs` | Static id → position + radius. |
|
||||
| `server/NeonSprawl.Server/Game/World/HorizontalReach.cs` (or under `Interaction/`) | Shared horizontal distance + within-radius helpers. |
|
||||
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | `MapInteractionApi` route registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs` | Boundary unit tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs` | Integration tests via `WebApplicationFactory`. |
|
||||
| `client/scripts/interaction_request_client.gd` (or name aligned with repo) | HTTP POST interact + debug print. |
|
||||
| `client/scripts/prototype_interaction_constants.gd` | **`const`** anchor **X/Z/Y** + **`interaction_radius`** + id string — **must match** `PrototypeInteractableRegistry.cs`. |
|
||||
| `client/scripts/interaction_radius_indicators.gd` (or merged name) | Drive **emission/modulate** on marker meshes from player snap position vs **`prototype_interaction_constants`**. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapInteractionApi()`. |
|
||||
| `client/scenes/main.tscn` | Add prototype interactable + **2+ glow marker meshes** (no collision); materials suitable for emission toggle. |
|
||||
| `client/scripts/main.gd` | Compose interaction client (signals/exports). |
|
||||
| `client/README.md` | NS-18 manual check: markers **glow** in-range / **dim** out-of-range (preview = same XZ+radius as server); walk + **E**; compare **Output** to glow. |
|
||||
| `server/README.md` | Interaction API + reason codes + distance rule. |
|
||||
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Implementation snapshot for NS-18. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Optional tracking row update for E1.M1. |
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unit:** `HorizontalReach` / distance helper—**exactly at** radius → **in range** (`<=`); **just outside** (slightly `> radius`) → out of range; document float tolerance if any.
|
||||
- **Integration:** seeded player **in range** → **200**, `allowed: true`, **no** `reasonCode` (or null per serializer policy—document); **out of range** / **unknown interactable** → **200**, `allowed: false`, **non-empty** `reasonCode`; **`interactableId` missing or whitespace-only** → **400**; **unknown player** → **404**; **POST move** then **POST interact** updates outcome.
|
||||
- **Manual:** Godot F5 + server; walk until markers **glow** (in range) vs **dim** (out); press interact on **E**; confirm **Output** **`allowed` / `reasonCode`** matches (glow ⇒ expect allow at same constants; if mismatch, fix shared numbers or server bug).
|
||||
|
||||
## Deferred (not this story)
|
||||
|
||||
- **Multi-floor / vertical placement:** Revisit distance math, interactable placement, and any **Y**-aware rules when stacked levels matter; until then **floor-plane reach only**.
|
||||
|
||||
## Pull request description (Jira AC) — draft for merge
|
||||
|
||||
**InteractionRequest v1**
|
||||
|
||||
- **Endpoint:** `POST /game/players/{id}/interact`
|
||||
- **HTTP:** **`404`** = unknown player only. **`400`** = malformed / invalid v1 request. Any **handled** attempt for a **known** player returns **`200`** with **`InteractionResponse`** (including `allowed: false` and `reasonCode`).
|
||||
- **Body (example):**
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"interactableId": "prototype_terminal"
|
||||
}
|
||||
```
|
||||
|
||||
| JSON property | Type (v1) | Meaning |
|
||||
|---------------|-----------|---------|
|
||||
| `schemaVersion` | integer | Must be `1` for v1. |
|
||||
| `interactableId` | string | Prototype interactable id (server static table). |
|
||||
|
||||
**`interactableId` rules (v1):** **Trim** whitespace; **case-insensitive** lookup vs **lowercase** registry keys. **Missing** or **empty after trim** → **400**. Unknown id after normalization → **200** + `unknown_interactable`.
|
||||
|
||||
**InteractionResponse v1 (example allowed)**
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"allowed": true,
|
||||
"interactableId": "prototype_terminal",
|
||||
"payload": { "kind": "placeholder" }
|
||||
}
|
||||
```
|
||||
|
||||
**InteractionResponse v1 (example denied)**
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"allowed": false,
|
||||
"reasonCode": "out_of_range"
|
||||
}
|
||||
```
|
||||
|
||||
| `reasonCode` (v1) | When | HTTP |
|
||||
|-------------------|------|------|
|
||||
| `out_of_range` | Horizontal distance **>** `interactionRadius` (on the radius is **allowed** — inclusive `<=`). | **200** |
|
||||
| `unknown_interactable` | Id not in prototype table. | **200** |
|
||||
|
||||
**Not in body:** Unknown player → **404** (no `InteractionResponse`). Malformed request → **400**.
|
||||
|
||||
**`allowed: false`:** body **must** include **`reasonCode`**. **`allowed: true`:** omit **`reasonCode`**; **`payload`** optional.
|
||||
|
||||
**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