NEO-83: Fix Bruno CI combat-targets HP state pollution.

Add dev combat-targets fixture reset endpoint, Bruno pre-request helper, and enable fixture in CI Bruno step so combat-targets tests run after ability-cast defeat spine.
pull/117/head
VinPropane 2026-05-25 11:28:13 -04:00
parent 24eb355a26
commit ea13b05196
13 changed files with 173 additions and 2 deletions

View File

@ -92,6 +92,7 @@ jobs:
env: env:
ConnectionStrings__NeonSprawl: >- ConnectionStrings__NeonSprawl: >-
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
Game__EnableCombatTargetFixtureApi: "true"
run: | run: |
set -euo pipefail set -euo pipefail
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \

View File

@ -10,6 +10,7 @@ docs {
script:pre-request { script:pre-request {
const axios = require("axios"); const axios = require("axios");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } }; const jsonHeaders = { headers: { "Content-Type": "application/json" } };
@ -18,6 +19,8 @@ script:pre-request {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
await resetPrototypeCombatTargets(bru);
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/move`, `${baseUrl}/game/players/${playerId}/move`,
{ schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } },

View File

@ -10,10 +10,13 @@ docs {
script:pre-request { script:pre-request {
const axios = require("axios"); const axios = require("axios");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } }; const jsonHeaders = { headers: { "Content-Type": "application/json" } };
await resetPrototypeCombatTargets(bru);
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/move`, `${baseUrl}/game/players/${playerId}/move`,
{ schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } },

View File

@ -4,6 +4,11 @@ meta {
seq: 1 seq: 1
} }
script:pre-request {
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
await resetPrototypeCombatTargets(bru);
}
get { get {
url: {{baseUrl}}/game/world/combat-targets url: {{baseUrl}}/game/world/combat-targets
body: none body: none

View File

@ -3,6 +3,7 @@ meta {
} }
docs { docs {
NEO-83: fresh GET on seq 1; one-pulse cast→GET on seq 2 (~0s); four-pulse defeat spine on seq 3 (~13s pre-request). NEO-83: fresh GET on seq 1 (~reset fixture); one-pulse cast→GET on seq 2 (~0s); four-pulse defeat spine on seq 3 (~13s pre-request).
Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution.
Cross-link: bruno/neon-sprawl-server/ability-cast/ for cast-only verification. Cross-link: bruno/neon-sprawl-server/ability-cast/ for cast-only verification.
} }

View File

@ -0,0 +1,26 @@
const axios = require("axios");
function resolveConfig(bru) {
return {
baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"),
jsonHeaders: { headers: { "Content-Type": "application/json" } },
};
}
async function resetPrototypeCombatTargets(bru) {
const { baseUrl, jsonHeaders } = resolveConfig(bru);
const response = await axios.post(
`${baseUrl}/game/__dev/combat-targets-fixture`,
{ schemaVersion: 1 },
jsonHeaders,
);
if (response.status !== 200 || response.data?.applied !== true) {
throw new Error(
`combat-targets fixture reset failed: ${response.status} ${JSON.stringify(response.data)}`,
);
}
}
module.exports = {
resetPrototypeCombatTargets,
};

View File

@ -0,0 +1,64 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class CombatTargetFixtureApiTests
{
private const string FixturePath = "/game/__dev/combat-targets-fixture";
[Fact]
public async Task PostCombatTargetFixture_ShouldResetPrototypeTargets_WhenEnabled()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
// Act
var response = await client.PostAsJsonAsync(
FixturePath,
new CombatTargetFixtureRequest
{
SchemaVersion = CombatTargetFixtureRequest.CurrentSchemaVersion,
});
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CombatTargetFixtureResponse>();
Assert.NotNull(body);
Assert.True(body!.Applied);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp);
Assert.False(alpha.Defeated);
}
[Fact]
public async Task PostCombatTargetFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.WithWebHostBuilder(b =>
{
b.UseEnvironment("Production");
b.UseSetting("Game:EnableCombatTargetFixtureApi", "false");
}).CreateClient();
// Act
var response = await client.PostAsJsonAsync(
FixturePath,
new CombatTargetFixtureRequest
{
SchemaVersion = CombatTargetFixtureRequest.CurrentSchemaVersion,
});
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View File

@ -0,0 +1,36 @@
using NeonSprawl.Server.Game.Targeting;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Dev-only route to reset prototype combat dummy HP for Bruno and manual QA (NEO-83).</summary>
public static class CombatTargetFixtureApi
{
public static WebApplication MapCombatTargetFixtureApi(this WebApplication app)
{
app.MapPost(
"/game/__dev/combat-targets-fixture",
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore) =>
{
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
foreach (var id in PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder())
{
if (!healthStore.TryResetToFull(id, out _))
{
return Results.NotFound();
}
}
return Results.Json(
new CombatTargetFixtureResponse
{
Applied = true,
});
});
return app;
}
}

View File

@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>JSON body for <c>POST /game/__dev/combat-targets-fixture</c> (NEO-83 Bruno/QA).</summary>
public sealed class CombatTargetFixtureRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
}
/// <summary>JSON response when the dev combat-target fixture applies.</summary>
public sealed class CombatTargetFixtureResponse
{
[JsonPropertyName("applied")]
public required bool Applied { get; init; }
}

View File

@ -13,6 +13,9 @@ public sealed class GamePositionOptions
/// <summary>When true, maps <c>POST …/__dev/mastery-fixture</c> for Bruno/manual QA state setup (also enabled in Development).</summary> /// <summary>When true, maps <c>POST …/__dev/mastery-fixture</c> for Bruno/manual QA state setup (also enabled in Development).</summary>
public bool EnableMasteryFixtureApi { get; set; } public bool EnableMasteryFixtureApi { get; set; }
/// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary>
public bool EnableCombatTargetFixtureApi { get; set; }
/// <summary>World position for the dev player at process start.</summary> /// <summary>World position for the dev player at process start.</summary>
public DefaultPositionOptions DefaultPosition { get; set; } = new(); public DefaultPositionOptions DefaultPosition { get; set; } = new();

View File

@ -62,6 +62,13 @@ if (app.Environment.IsDevelopment() ||
app.MapMasteryFixtureApi(); app.MapMasteryFixtureApi();
} }
if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") ||
app.Configuration.GetValue<bool>("Game:EnableCombatTargetFixtureApi"))
{
app.MapCombatTargetFixtureApi();
}
app.MapTargetingApi(); app.MapTargetingApi();
app.MapHotbarLoadoutApi(); app.MapHotbarLoadoutApi();
app.MapCooldownSnapshotApi(); app.MapCooldownSnapshotApi();

View File

@ -6,6 +6,7 @@
} }
}, },
"Game": { "Game": {
"EnableMasteryFixtureApi": true "EnableMasteryFixtureApi": true,
"EnableCombatTargetFixtureApi": true
} }
} }

View File

@ -129,6 +129,8 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m
curl -sS -i "http://localhost:5253/game/world/combat-targets" curl -sS -i "http://localhost:5253/game/world/combat-targets"
``` ```
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeTargetRegistry`** dummies to full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
## Combat engine (NEO-81) ## Combat engine (NEO-81)
**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept. **`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept.