diff --git a/client/README.md b/client/README.md index 09b21b1..e8f576f 100644 --- a/client/README.md +++ b/client/README.md @@ -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. diff --git a/client/project.godot b/client/project.godot index be34e38..184d3c8 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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" diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index c9cdd7c..fbec954 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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") diff --git a/client/scripts/interaction_radius_indicators.gd b/client/scripts/interaction_radius_indicators.gd new file mode 100644 index 0000000..591043d --- /dev/null +++ b/client/scripts/interaction_radius_indicators.gd @@ -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 diff --git a/client/scripts/interaction_radius_indicators.gd.uid b/client/scripts/interaction_radius_indicators.gd.uid new file mode 100644 index 0000000..f6492f1 --- /dev/null +++ b/client/scripts/interaction_radius_indicators.gd.uid @@ -0,0 +1 @@ +uid://n3p4f3fky3nv diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd new file mode 100644 index 0000000..667855e --- /dev/null +++ b/client/scripts/interaction_request_client.gd @@ -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]) diff --git a/client/scripts/interaction_request_client.gd.uid b/client/scripts/interaction_request_client.gd.uid new file mode 100644 index 0000000..b1a1019 --- /dev/null +++ b/client/scripts/interaction_request_client.gd.uid @@ -0,0 +1 @@ +uid://bv0xprp660hib diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 341f974..f488d6d 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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: diff --git a/client/scripts/prototype_interaction_constants.gd b/client/scripts/prototype_interaction_constants.gd new file mode 100644 index 0000000..62bc4e6 --- /dev/null +++ b/client/scripts/prototype_interaction_constants.gd @@ -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 diff --git a/client/scripts/prototype_interaction_constants.gd.uid b/client/scripts/prototype_interaction_constants.gd.uid new file mode 100644 index 0000000..15069b4 --- /dev/null +++ b/client/scripts/prototype_interaction_constants.gd.uid @@ -0,0 +1 @@ +uid://c8diye55s37rr diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index 1a59d22..c40aa63 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 077a222..74fe128 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/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) | --- diff --git a/docs/plans/NS-18-implementation-plan.md b/docs/plans/NS-18-implementation-plan.md new file mode 100644 index 0000000..c18a330 --- /dev/null +++ b/docs/plans/NS-18-implementation-plan.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. diff --git a/docs/reviews/2026-04-04-NS-18.md b/docs/reviews/2026-04-04-NS-18.md new file mode 100644 index 0000000..2d5f3ea --- /dev/null +++ b/docs/reviews/2026-04-04-NS-18.md @@ -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).* diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs new file mode 100644 index 0000000..e1d6ef1 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs @@ -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(() => + HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0)); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs new file mode 100644 index 0000000..e8c8997 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs @@ -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; + +/// Integration tests for (NS-18). +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + Assert.True(nearBody!.Allowed); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs index 31e2d13..e232a24 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs @@ -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] diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs index 8d5a59b..dfb8d44 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresCollection.cs @@ -3,6 +3,6 @@ using Xunit; namespace NeonSprawl.Server.Tests.Game.PositionState; [CollectionDefinition("Postgres integration")] -public sealed class PostgresCollection : ICollectionFixture +public sealed class PostgresCollection : ICollectionFixture { } diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresDockerHelper.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresDockerHelper.cs new file mode 100644 index 0000000..50373da --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresDockerHelper.cs @@ -0,0 +1,203 @@ +using System.Diagnostics; +using Npgsql; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +/// +/// When not in CI, tries TCP to Postgres; if unreachable, runs docker compose up -d from the repo root. +/// Tracks whether compose was started so teardown can run docker compose down. +/// +/// +/// All public entry points avoid GetAwaiter().GetResult() on the xUnit synchronization context; +/// defers to the thread pool to prevent Rider/VS deadlocks. +/// +internal static class PostgresDockerHelper +{ + private static readonly object StateLock = new(); + private static Task? _ensureTask; + private static bool _composeStartedByUs; + private static string? _repoRoot; + + /// Async path for (no deadlock). + internal static Task EnsurePostgresOrStartDockerAsync(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return Task.CompletedTask; + } + + lock (StateLock) + { + _ensureTask ??= EnsureCoreAsync(connectionString.Trim()); + return _ensureTask; + } + } + + /// + /// Used from (sync only). Runs work on the thread pool + /// so Npgsql / Task continuations do not post back to a blocked xUnit context. + /// + 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 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; + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresIntegrationHarness.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresIntegrationHarness.cs new file mode 100644 index 0000000..6ac21a9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresIntegrationHarness.cs @@ -0,0 +1,25 @@ +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +/// +/// Collection fixture: ensures Postgres is reachable (starts Docker Compose from repo root when not in CI), +/// exposes the shared , then disposes the factory and runs +/// docker compose down only if this harness started Compose. +/// +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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs index 2302247..8b2555a 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs @@ -10,14 +10,15 @@ namespace NeonSprawl.Server.Tests.Game.PositionState; /// NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database. [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>().Value; + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); if (!File.Exists(ddlPath)) diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 73ea3d0..b8a96b6 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -10,6 +10,7 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactoryenable NeonSprawl.Server.Tests false + + $(MSBuildProjectDirectory)\postgres.runsettings diff --git a/server/NeonSprawl.Server.Tests/Properties/launchSettings.json b/server/NeonSprawl.Server.Tests/Properties/launchSettings.json new file mode 100644 index 0000000..b287da7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/server/NeonSprawl.Server.Tests/postgres.runsettings b/server/NeonSprawl.Server.Tests/postgres.runsettings new file mode 100644 index 0000000..949db9f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/postgres.runsettings @@ -0,0 +1,9 @@ + + + + + + Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev + + + diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs new file mode 100644 index 0000000..59b1c16 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -0,0 +1,81 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; + +namespace NeonSprawl.Server.Game.Interaction; + +/// Maps POST /game/players/{{id}}/interact (NS-18). +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; + } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionRequest.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionRequest.cs new file mode 100644 index 0000000..ef72ab6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionRequest.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Interaction; + +/// POST body for POST /game/players/{{id}}/interact (NS-18). +/// +/// interactableId is trimmed; empty after trim → HTTP 400. Lookup is case-insensitive against lowercase registry keys. +/// +public sealed class InteractionRequest +{ + public const int CurrentSchemaVersion = 1; + + /// Must be for v1. + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// Registry key after trim + case-insensitive match. + [JsonPropertyName("interactableId")] + public string? InteractableId { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionResponse.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionResponse.cs new file mode 100644 index 0000000..d6ab4c2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionResponse.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Interaction; + +/// JSON body for every handled interaction attempt (HTTP 200 only; NS-18). +/// +/// When is false, is required and must be non-empty. +/// When is true, omit ; is optional. +/// Unknown player → HTTP 404 (no body). Malformed v1 request → HTTP 400. +/// +public sealed class InteractionResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("allowed")] + public bool Allowed { get; init; } + + /// Stable machine string when denied; omitted when allowed. + [JsonPropertyName("reasonCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasonCode { get; init; } + + /// Echo canonical id on success; omitted when denied. + [JsonPropertyName("interactableId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InteractableId { get; init; } + + [JsonPropertyName("payload")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public InteractionPlaceholderPayload? Payload { get; init; } +} + +/// Placeholder success payload for prototype wiring. +public sealed class InteractionPlaceholderPayload +{ + [JsonPropertyName("kind")] + public string Kind { get; init; } = "placeholder"; +} diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs new file mode 100644 index 0000000..b97e6fc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs @@ -0,0 +1,22 @@ +namespace NeonSprawl.Server.Game.Interaction; + +/// Prototype id → world anchor + reach. Keys are lowercase; lookup after trim + case fold (NS-18). +public static class PrototypeInteractableRegistry +{ + /// Canonical lowercase id for the single prototype row. + public const string PrototypeTerminalId = "prototype_terminal"; + + /// Source-of-truth anchor and radius; keep in sync with client/scripts/prototype_interaction_constants.gd. + public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0); + + private static readonly Dictionary ById = new(StringComparer.Ordinal) + { + [PrototypeTerminalId] = PrototypeTerminal, + }; + + public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) => + ById.TryGetValue(interactableIdLowercase, out entry); +} + +/// Horizontal reach on X/Z; inclusive boundary. +public readonly record struct PrototypeInteractableEntry(double X, double Y, double Z, double InteractionRadius); diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateResponse.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateResponse.cs index 30246b2..43055a5 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateResponse.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateResponse.cs @@ -7,7 +7,7 @@ namespace NeonSprawl.Server.Game.PositionState; /// /// Example v1 payload (initial seed; increases after each successful move, NS-16): /// -/// {"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} /// /// public sealed class PositionStateResponse diff --git a/server/NeonSprawl.Server/Game/World/HorizontalReach.cs b/server/NeonSprawl.Server/Game/World/HorizontalReach.cs new file mode 100644 index 0000000..9d14f3e --- /dev/null +++ b/server/NeonSprawl.Server/Game/World/HorizontalReach.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.World; + +/// +/// Horizontal (floor-plane) distance on X/Z only; Y is ignored for reach (NS-18). +/// +public static class HorizontalReach +{ + /// Planar distance sqrt(dx² + dz²) using only X and Z. + 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); + } + + /// true when <= radius (boundary inclusive). + /// is negative (invalid configuration). + public static bool IsWithinHorizontalRadius(double ax, double az, double bx, double bz, double radius) + { + ArgumentOutOfRangeException.ThrowIfNegative(radius); + return HorizontalDistance(ax, az, bx, bz) <= radius; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index e48842a..be4ae6a 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); diff --git a/server/NeonSprawl.Server/appsettings.Development.json b/server/NeonSprawl.Server/appsettings.Development.json index 0c208ae..3e805ed 100644 --- a/server/NeonSprawl.Server/appsettings.Development.json +++ b/server/NeonSprawl.Server/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information" } } } diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index 2bf62cf..a65a4d8 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -9,9 +9,9 @@ "Game": { "DevPlayerId": "dev-local-1", "DefaultPosition": { - "X": 0, - "Y": 0, - "Z": 0 + "X": -5, + "Y": 0.9, + "Z": -5 } } } diff --git a/server/README.md b/server/README.md index 1391943..9ca7918 100644 --- a/server/README.md +++ b/server/README.md @@ -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`.