From f4ba4cdd9203bfb231c1f74ed90329aa99f56cb8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Thu, 30 Apr 2026 21:37:03 -0400 Subject: [PATCH] NEO-32: optional NEON_SPRAWL_API_LOG stdout for ability-cast --- .../ability-cast/Post cast happy.bru | 1 + docs/manual-qa/NEO-30.md | 3 +- docs/manual-qa/NEO-32.md | 2 +- .../Game/AbilityInput/AbilityCastApiTests.cs | 1 + .../Diagnostics/PrototypeApiConsoleLog.cs | 55 +++++++++++++++++ .../Game/AbilityInput/AbilityCastApi.cs | 60 +++++++++++++++---- .../Properties/launchSettings.json | 10 ++++ server/README.md | 10 +++- 8 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 server/NeonSprawl.Server/Diagnostics/PrototypeApiConsoleLog.cs diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index e076b31..07b2c64 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -22,6 +22,7 @@ body:json { tests { test("status 200", function () { // NEO-30 review follow-up: cast JSON contract unchanged (server comments only). + // Optional: NEON_SPRAWL_API_LOG=1 on server prints ability_cast_requested for accepted JSON. expect(res.getStatus()).to.equal(200); }); diff --git a/docs/manual-qa/NEO-30.md b/docs/manual-qa/NEO-30.md index 784285c..1eaf29d 100644 --- a/docs/manual-qa/NEO-30.md +++ b/docs/manual-qa/NEO-30.md @@ -10,13 +10,14 @@ ## 1) Code / docs sanity -- [ ] Open `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` and confirm **NEO-30** comments mark **`ability_cast_denied`** on each `Results.Json` deny and **`ability_cast_requested`** on the final accept return; each deny branch notes **TODO(E9.M1)**. +- [ ] Open `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` and confirm **NEO-30** comments mark **`ability_cast_denied`** on each JSON deny return and **`ability_cast_requested`** on the final accept return; each deny branch notes **TODO(E9.M1)**. - [ ] Confirm **400** / **404** early returns are documented as **not** Slice 3 `ability_cast_denied` JSON paths. - [ ] Skim `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` — snapshot mentions NEO-30 hook file paths; **E1M4-05** row links to Linear **NEO-30**. ## 2) Client hook vocabulary (regression with NEO-31 / NEO-28) - [ ] With server running and slot **0** bound to `prototype_pulse`, **Tab** lock **alpha**, press **1**: Godot Output line starts with **`ability_cast_requested`** and includes `slot=0`, `ability_id=prototype_pulse`, `targetId=` consistent with HUD lock. +- [ ] **Optional (server stdout):** same scenario with **`NEON_SPRAWL_API_LOG=1`** (see [server README — Run](../../server/README.md)) — server console shows **`[ability-cast] ability_cast_requested`** for that accept (Slice 3 hook name alignment). - [ ] **Without** lock (or wrong target), press **1**: **`CastFeedbackLabel`** shows **`ability_cast_denied: …`** with a **non-empty** reason; Output warning includes **`ability_cast_denied`** and **`reasonCode=`** (see [`docs/manual-qa/NEO-28.md`](NEO-28.md) for concrete cases). ## 3) Server JSON deny (reason non-empty) diff --git a/docs/manual-qa/NEO-32.md b/docs/manual-qa/NEO-32.md index 2e0c601..96ce6ab 100644 --- a/docs/manual-qa/NEO-32.md +++ b/docs/manual-qa/NEO-32.md @@ -6,6 +6,6 @@ Prereq: game server running (`NeonSprawl.Server`), client pointed at same `base_ 2. **Server deny on spam** — Immediately double-tap cast during cooling (or remove client guard temporarily): second attempt should show `ability_cast_denied: on_cooldown` (server) on the cast feedback line when POST completes. -3. **Client guard** — While slot 0 shows cooling, press `1` repeatedly. Expect **`ability_cast_denied: on_cooldown (client guard)`** on **`CastFeedbackLabel`** on most presses (local mirror blocks the POST). No server console logging required. **Optional proof the server still enforces cooldown:** in Bruno (or `curl`), send **`POST …/ability-cast`** twice in a row with the same valid body while the snapshot still shows slot 0 cooling — second response **`accepted": false`**, **`reasonCode": "on_cooldown"`** (same contract as step 2). +3. **Client guard** — While slot 0 shows cooling, press `1` repeatedly. Expect **`ability_cast_denied: on_cooldown (client guard)`** on **`CastFeedbackLabel`** on most presses (local mirror blocks the POST). **Optional:** run the server with **`NEON_SPRAWL_API_LOG=1`** (or launch profile **`http+apiLog`**) — each cast that reaches the server and returns JSON prints **`[ability-cast] ability_cast_denied …`** or **`ability_cast_requested`** to the process console, so rapid `1` with the guard on should produce **fewer** lines than without the guard. **Optional proof the server still enforces cooldown:** in Bruno (or `curl`), send **`POST …/ability-cast`** twice in a row with the same valid body while the snapshot still shows slot 0 cooling — second response **`accepted": false`**, **`reasonCode": "on_cooldown"`** (same contract as step 2). 4. **Reconnect / rehydrate** — With slot 0 mid-cooldown, restart the client (or reload scene) while server keeps running; after boot expect cooldown line to match server within one GET cycle (same dev player id). diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 2203bc9..9787621 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -13,6 +13,7 @@ namespace NeonSprawl.Server.Tests.Game.AbilityInput; /// NEO-30: every JSON deny ( false) asserts a non-whitespace /// — matches Slice 3 ability_cast_denied payload expectations; /// authoritative hook-site comments live on (cast route: MapAbilityCastApi extension in the same source file). +/// Optional dev: NEON_SPRAWL_API_LOG=1 enables one-line ability_cast_* stdout per JSON cast response (see ). /// public sealed class AbilityCastApiTests { diff --git a/server/NeonSprawl.Server/Diagnostics/PrototypeApiConsoleLog.cs b/server/NeonSprawl.Server/Diagnostics/PrototypeApiConsoleLog.cs new file mode 100644 index 0000000..e9e8e13 --- /dev/null +++ b/server/NeonSprawl.Server/Diagnostics/PrototypeApiConsoleLog.cs @@ -0,0 +1,55 @@ +namespace NeonSprawl.Server.Diagnostics; + +/// Optional one-line stdout traces for prototype HTTP routes (manual QA / dev until E9.M1). +/// +/// Enable with environment variable NEON_SPRAWL_API_LOG set to 1, true, yes, or on (case-insensitive). +/// +public static class PrototypeApiConsoleLog +{ + /// Slice 3 hook names; matches product telemetry vocabulary in AbilityCastApi comments. + public static bool IsAbilityCastStdoutEnabled() + { + var raw = Environment.GetEnvironmentVariable("NEON_SPRAWL_API_LOG"); + if (string.IsNullOrWhiteSpace(raw)) + { + return false; + } + + var v = raw.Trim(); + return v is "1" or "true" or "yes" + || string.Equals(v, "on", StringComparison.OrdinalIgnoreCase); + } + + /// One line per JSON ability-cast response (HTTP 200 with AbilityCastResponse). + public static void WriteAbilityCastLine( + string playerId, + int slotIndex, + string? abilityId, + string? targetId, + bool accepted, + string? reasonCode) + { + if (!IsAbilityCastStdoutEnabled()) + { + return; + } + + if (accepted) + { + Console.WriteLine( + "[ability-cast] ability_cast_requested player={0} slot={1} ability={2} target={3}", + playerId, + slotIndex, + abilityId ?? "", + targetId ?? ""); + } + else + { + Console.WriteLine( + "[ability-cast] ability_cast_denied player={0} slot={1} reason={2}", + playerId, + slotIndex, + reasonCode ?? ""); + } + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index d8e5518..fec55fc 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.World; @@ -9,6 +10,7 @@ namespace NeonSprawl.Server.Game.AbilityInput; /// Epic 1 Slice 3 product telemetry names ability_cast_requested and ability_cast_denied /// (on JSON denies, — wire JSON reasonCode) — hook sites are marked inline below. /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). +/// Optional dev stdout: set NEON_SPRAWL_API_LOG=1 (see ) for one line per JSON cast response. /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON AbilityCastResponse). /// NEO-32: JSON deny on_cooldown when the slot is inside the prototype global cooldown window. /// @@ -28,6 +30,22 @@ public static class AbilityCastApi /// Slot still inside server-authoritative cooldown window (NEO-32). public const string ReasonOnCooldown = "on_cooldown"; + private static IResult JsonAbilityCast( + string id, + AbilityCastRequest body, + AbilityCastResponse response, + string? abilityIdForLog = null) + { + PrototypeApiConsoleLog.WriteAbilityCastLine( + id, + body.SlotIndex, + abilityIdForLog ?? body.AbilityId, + body.TargetId, + response.Accepted, + response.ReasonCode); + return Results.Json(response); + } + public static WebApplication MapAbilityCastApi(this WebApplication app) { app.MapPost( @@ -62,7 +80,9 @@ public static class AbilityCastApi if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, @@ -74,7 +94,9 @@ public static class AbilityCastApi string.IsNullOrWhiteSpace(boundAbility)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, @@ -86,7 +108,9 @@ public static class AbilityCastApi !PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, @@ -97,7 +121,9 @@ public static class AbilityCastApi if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, @@ -108,7 +134,9 @@ public static class AbilityCastApi if (string.IsNullOrWhiteSpace(body.TargetId)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); } @@ -116,7 +144,9 @@ public static class AbilityCastApi if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); } @@ -125,14 +155,18 @@ public static class AbilityCastApi !string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); } if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange }); } @@ -140,7 +174,9 @@ public static class AbilityCastApi if (cooldowns.IsOnCooldown(id, body.SlotIndex, now)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). - return Results.Json( + return JsonAbilityCast( + id, + body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown }); } @@ -148,7 +184,11 @@ public static class AbilityCastApi // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). - return Results.Json(new AbilityCastResponse { Accepted = true }); + return JsonAbilityCast( + id, + body, + new AbilityCastResponse { Accepted = true }, + normalizedRequest); }); return app; diff --git a/server/NeonSprawl.Server/Properties/launchSettings.json b/server/NeonSprawl.Server/Properties/launchSettings.json index 7a8e7ca..90f02ec 100644 --- a/server/NeonSprawl.Server/Properties/launchSettings.json +++ b/server/NeonSprawl.Server/Properties/launchSettings.json @@ -18,6 +18,16 @@ "ASPNETCORE_ENVIRONMENT": "Development" } }, + "http+apiLog": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "NEON_SPRAWL_API_LOG": "1" + } + }, "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, diff --git a/server/README.md b/server/README.md index c8014a1..a23eb52 100644 --- a/server/README.md +++ b/server/README.md @@ -16,6 +16,14 @@ dotnet run - Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`). - Check `GET /health` for a JSON heartbeat. +**Optional prototype API stdout (manual QA / dev):** set **`NEON_SPRAWL_API_LOG`** to **`1`**, **`true`**, **`yes`**, or **`on`** (case-insensitive). While enabled, each **`POST …/ability-cast`** that returns JSON **`AbilityCastResponse`** (HTTP 200) prints one line to the process console: either **`ability_cast_requested`** (accept) or **`ability_cast_denied`** with **`reason=`** (deny). **`400`** / **`404`** paths do not emit these lines. Implementation: `Diagnostics/PrototypeApiConsoleLog.cs` (wired from `AbilityCastApi`). Example: + +```bash +NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj +``` + +IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable). + ## Position persistence (NEO-8) 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). @@ -249,7 +257,7 @@ Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** | `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). | | `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful accept (NEO-32). | -Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. +Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above). ## Cooldown snapshot (NEO-32)