NEO-32: optional NEON_SPRAWL_API_LOG stdout for ability-cast
parent
ae74f570dc
commit
f4ba4cdd92
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// <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).
|
||||
/// 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>
|
||||
public sealed class AbilityCastApiTests
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <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.
|
||||
/// 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>).
|
||||
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the prototype global cooldown window.
|
||||
/// </remarks>
|
||||
|
|
@ -28,6 +30,22 @@ public static class AbilityCastApi
|
|||
/// <summary>Slot still inside server-authoritative cooldown window (NEO-32).</summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue