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)