NEO-32: optional NEON_SPRAWL_API_LOG stdout for ability-cast

pull/59/head
VinPropane 2026-04-30 21:37:03 -04:00
parent ae74f570dc
commit f4ba4cdd92
8 changed files with 129 additions and 13 deletions

View File

@ -22,6 +22,7 @@ body:json {
tests { tests {
test("status 200", function () { test("status 200", function () {
// NEO-30 review follow-up: cast JSON contract unchanged (server comments only). // 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); expect(res.getStatus()).to.equal(200);
}); });

View File

@ -10,13 +10,14 @@
## 1) Code / docs sanity ## 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. - [ ] 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**. - [ ] 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) ## 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. - [ ] 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). - [ ] **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) ## 3) Server JSON deny (reason non-empty)

View File

@ -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. 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). 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).

View File

@ -13,6 +13,7 @@ namespace NeonSprawl.Server.Tests.Game.AbilityInput;
/// NEO-30: every JSON deny (<see cref="AbilityCastResponse.Accepted"/> <c>false</c>) asserts a non-whitespace /// NEO-30: every JSON deny (<see cref="AbilityCastResponse.Accepted"/> <c>false</c>) asserts a non-whitespace
/// <see cref="AbilityCastResponse.ReasonCode"/> — matches Slice 3 <c>ability_cast_denied</c> payload expectations; /// <see cref="AbilityCastResponse.ReasonCode"/> — matches Slice 3 <c>ability_cast_denied</c> payload expectations;
/// authoritative hook-site comments live on <see cref="AbilityCastApi"/> (cast route: <c>MapAbilityCastApi</c> extension in the same source file). /// authoritative hook-site comments live on <see cref="AbilityCastApi"/> (cast route: <c>MapAbilityCastApi</c> extension in the same source file).
/// Optional dev: <c>NEON_SPRAWL_API_LOG=1</c> enables one-line <c>ability_cast_*</c> stdout per JSON cast response (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>).
/// </remarks> /// </remarks>
public sealed class AbilityCastApiTests public sealed class AbilityCastApiTests
{ {

View File

@ -0,0 +1,55 @@
namespace NeonSprawl.Server.Diagnostics;
/// <summary>Optional one-line stdout traces for prototype HTTP routes (manual QA / dev until E9.M1).</summary>
/// <remarks>
/// Enable with environment variable <c>NEON_SPRAWL_API_LOG</c> set to <c>1</c>, <c>true</c>, <c>yes</c>, or <c>on</c> (case-insensitive).
/// </remarks>
public static class PrototypeApiConsoleLog
{
/// <summary>Slice 3 hook names; matches product telemetry vocabulary in <c>AbilityCastApi</c> comments.</summary>
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);
}
/// <summary>One line per JSON ability-cast response (HTTP 200 with <c>AbilityCastResponse</c>).</summary>
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 ?? "");
}
}
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -9,6 +10,7 @@ namespace NeonSprawl.Server.Game.AbilityInput;
/// Epic 1 Slice 3 product telemetry names <c>ability_cast_requested</c> and <c>ability_cast_denied</c> /// Epic 1 Slice 3 product telemetry names <c>ability_cast_requested</c> and <c>ability_cast_denied</c>
/// (on JSON denies, <see cref="AbilityCastResponse.ReasonCode"/> — wire JSON <c>reasonCode</c>) — hook sites are marked inline below. /// (on JSON denies, <see cref="AbilityCastResponse.ReasonCode"/> — wire JSON <c>reasonCode</c>) — hook sites are marked inline below.
/// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode).
/// Optional dev stdout: set <c>NEON_SPRAWL_API_LOG=1</c> (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>) for one line per JSON cast response.
/// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>). /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>).
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the prototype global cooldown window. /// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the prototype global cooldown window.
/// </remarks> /// </remarks>
@ -28,6 +30,22 @@ public static class AbilityCastApi
/// <summary>Slot still inside server-authoritative cooldown window (NEO-32).</summary> /// <summary>Slot still inside server-authoritative cooldown window (NEO-32).</summary>
public const string ReasonOnCooldown = "on_cooldown"; 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) public static WebApplication MapAbilityCastApi(this WebApplication app)
{ {
app.MapPost( app.MapPost(
@ -62,7 +80,9 @@ public static class AbilityCastApi
if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1) if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1)
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return Results.Json( return JsonAbilityCast(
id,
body,
new AbilityCastResponse new AbilityCastResponse
{ {
Accepted = false, Accepted = false,
@ -74,7 +94,9 @@ public static class AbilityCastApi
string.IsNullOrWhiteSpace(boundAbility)) string.IsNullOrWhiteSpace(boundAbility))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return Results.Json( return JsonAbilityCast(
id,
body,
new AbilityCastResponse new AbilityCastResponse
{ {
Accepted = false, Accepted = false,
@ -86,7 +108,9 @@ public static class AbilityCastApi
!PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest)) !PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return Results.Json( return JsonAbilityCast(
id,
body,
new AbilityCastResponse new AbilityCastResponse
{ {
Accepted = false, Accepted = false,
@ -97,7 +121,9 @@ public static class AbilityCastApi
if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal)) if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return Results.Json( return JsonAbilityCast(
id,
body,
new AbilityCastResponse new AbilityCastResponse
{ {
Accepted = false, Accepted = false,
@ -108,7 +134,9 @@ public static class AbilityCastApi
if (string.IsNullOrWhiteSpace(body.TargetId)) if (string.IsNullOrWhiteSpace(body.TargetId))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // 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 }); new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
} }
@ -116,7 +144,9 @@ public static class AbilityCastApi
if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // 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 }); new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
} }
@ -125,14 +155,18 @@ public static class AbilityCastApi
!string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal)) !string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // 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 }); new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
} }
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius)) 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). // 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 }); new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange });
} }
@ -140,7 +174,9 @@ public static class AbilityCastApi
if (cooldowns.IsOnCooldown(id, body.SlotIndex, now)) if (cooldowns.IsOnCooldown(id, body.SlotIndex, now))
{ {
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). // 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 }); 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). // 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). // 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; return app;

View File

@ -18,6 +18,16 @@
"ASPNETCORE_ENVIRONMENT": "Development" "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": { "IIS Express": {
"commandName": "IISExpress", "commandName": "IISExpress",
"launchBrowser": true, "launchBrowser": true,

View File

@ -16,6 +16,14 @@ dotnet run
- Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`). - Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`).
- Check `GET /health` for a JSON heartbeat. - 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) ## 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 stores constructor seed (not on every HTTP request). When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x``sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory stores constructor seed (not on every HTTP request).
@ -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). | | `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). | | `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 3named** 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) ## Cooldown snapshot (NEO-32)