From ea13b05196d75a29c12ccd66a320feb9830a52c5 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:28:13 -0400 Subject: [PATCH] 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. --- .github/workflows/dotnet.yml | 1 + .../Get combat targets after cast spine.bru | 3 + .../Get combat targets after one cast.bru | 3 + .../combat-targets/Get combat targets.bru | 5 ++ .../combat-targets/folder.bru | 3 +- .../scripts/combat-targets-reset-helper.js | 26 ++++++++ .../Combat/CombatTargetFixtureApiTests.cs | 64 +++++++++++++++++++ .../Game/Combat/CombatTargetFixtureApi.cs | 36 +++++++++++ .../Game/Combat/CombatTargetFixtureDtos.cs | 19 ++++++ .../Game/PositionState/GamePositionOptions.cs | 3 + server/NeonSprawl.Server/Program.cs | 7 ++ .../appsettings.Development.json | 3 +- server/README.md | 2 + 13 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js create mode 100644 server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 5d0d606..f6cca95 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -92,6 +92,7 @@ jobs: env: ConnectionStrings__NeonSprawl: >- Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev + Game__EnableCombatTargetFixtureApi: "true" run: | set -euo pipefail dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru index a4bf633..ec5ad8f 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru @@ -10,6 +10,7 @@ docs { script:pre-request { const axios = require("axios"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; @@ -18,6 +19,8 @@ script:pre-request { return new Promise((resolve) => setTimeout(resolve, ms)); } + await resetPrototypeCombatTargets(bru); + await axios.post( `${baseUrl}/game/players/${playerId}/move`, { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru index 824a197..77578fa 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru @@ -10,10 +10,13 @@ docs { script:pre-request { const axios = require("axios"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + await resetPrototypeCombatTargets(bru); + await axios.post( `${baseUrl}/game/players/${playerId}/move`, { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru index 4538346..aaf7080 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru @@ -4,6 +4,11 @@ meta { seq: 1 } +script:pre-request { + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + await resetPrototypeCombatTargets(bru); +} + get { url: {{baseUrl}}/game/world/combat-targets body: none diff --git a/bruno/neon-sprawl-server/combat-targets/folder.bru b/bruno/neon-sprawl-server/combat-targets/folder.bru index 935d26f..8e04cd1 100644 --- a/bruno/neon-sprawl-server/combat-targets/folder.bru +++ b/bruno/neon-sprawl-server/combat-targets/folder.bru @@ -3,6 +3,7 @@ meta { } 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. } diff --git a/bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js b/bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js new file mode 100644 index 0000000..55cea7b --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js @@ -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, +}; diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs new file mode 100644 index 0000000..2e89d94 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -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(); + _ = 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(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs new file mode 100644 index 0000000..16b92e4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -0,0 +1,36 @@ +using NeonSprawl.Server.Game.Targeting; + +namespace NeonSprawl.Server.Game.Combat; + +/// Dev-only route to reset prototype combat dummy HP for Bruno and manual QA (NEO-83). +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; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs new file mode 100644 index 0000000..1af4c8e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Combat; + +/// JSON body for POST /game/__dev/combat-targets-fixture (NEO-83 Bruno/QA). +public sealed class CombatTargetFixtureRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } +} + +/// JSON response when the dev combat-target fixture applies. +public sealed class CombatTargetFixtureResponse +{ + [JsonPropertyName("applied")] + public required bool Applied { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs index 334b824..4a0d345 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -13,6 +13,9 @@ public sealed class GamePositionOptions /// When true, maps POST …/__dev/mastery-fixture for Bruno/manual QA state setup (also enabled in Development). public bool EnableMasteryFixtureApi { get; set; } + /// When true, maps POST /game/__dev/combat-targets-fixture to reset prototype dummy HP (also enabled in Development/Testing). + public bool EnableCombatTargetFixtureApi { get; set; } + /// World position for the dev player at process start. public DefaultPositionOptions DefaultPosition { get; set; } = new(); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 71368f9..b1889ed 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -62,6 +62,13 @@ if (app.Environment.IsDevelopment() || app.MapMasteryFixtureApi(); } +if (app.Environment.IsDevelopment() || + app.Environment.IsEnvironment("Testing") || + app.Configuration.GetValue("Game:EnableCombatTargetFixtureApi")) +{ + app.MapCombatTargetFixtureApi(); +} + app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi(); diff --git a/server/NeonSprawl.Server/appsettings.Development.json b/server/NeonSprawl.Server/appsettings.Development.json index d37883a..ceb318d 100644 --- a/server/NeonSprawl.Server/appsettings.Development.json +++ b/server/NeonSprawl.Server/appsettings.Development.json @@ -6,6 +6,7 @@ } }, "Game": { - "EnableMasteryFixtureApi": true + "EnableMasteryFixtureApi": true, + "EnableCombatTargetFixtureApi": true } } diff --git a/server/README.md b/server/README.md index 6b23c61..7d74509 100644 --- a/server/README.md +++ b/server/README.md @@ -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" ``` +**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) **`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.