NEO-81: Add CombatOperations.TryResolve and CombatResult.

Pure server combat resolution applies catalog damage via the health store,
denies re-hits on defeated targets, and validates unknown ability/target ids.
pull/115/head
VinPropane 2026-05-25 10:16:24 -04:00
parent 849c34f460
commit ca15e7a689
6 changed files with 375 additions and 5 deletions

View File

@ -45,11 +45,11 @@
## Acceptance criteria checklist
- [ ] **`prototype_pulse`** against full-HP dummy deals catalog damage (25) deterministically.
- [ ] Second resolve on defeated target returns structured deny (no silent no-op).
- [ ] Zero-damage utility abilities succeed with **`damageDealt = 0`** on live targets.
- [ ] Unknown ability / unknown target return stable deny codes from **`CombatOperations`**.
- [ ] Defeated-target deny applies to zero-damage utility abilities.
- [x] **`prototype_pulse`** against full-HP dummy deals catalog damage (25) deterministically.
- [x] Second resolve on defeated target returns structured deny (no silent no-op).
- [x] Zero-damage utility abilities succeed with **`damageDealt = 0`** on live targets.
- [x] Unknown ability / unknown target return stable deny codes from **`CombatOperations`**.
- [x] Defeated-target deny applies to zero-damage utility abilities.
## Technical approach
@ -138,3 +138,10 @@ No Bruno or manual QA for this story (server-only internal ops; no HTTP or clien
- **Zero-damage** abilities succeed via **`TryGet`** only (no store mutation).
- **Unknown ability/target** validated inside **`TryResolve`** with stable reason codes.
- **No HTTP** in NEO-81; cast wiring is [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82).
## Reconciliation (implementation)
- **`CombatReasonCodes`**, **`CombatResult`**, **`CombatOperations.TryResolve`** added under **`Game/Combat/`**.
- Defeated pre-check denies before damage; zero-damage path uses **`TryGet`** only.
- **9** AAA tests in **`CombatOperationsTests`** (all green), including host DI resolve.
- **`server/README.md`** — combat engine section with reason-code table and NEO-82 handoff.

View File

@ -0,0 +1,253 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class CombatOperationsTests
{
private static AbilityDefinitionRegistry CreatePrototypeRegistry()
{
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);
return new AbilityDefinitionRegistry(catalog);
}
[Fact]
public void TryResolve_WithPulseOnFreshAlpha_ShouldDealCatalogDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(25, result.DamageDealt);
Assert.Equal(75, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
}
[Fact]
public void TryResolve_WithFourPulses_ShouldDefeatTargetOnFourthResolve()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var first = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
var second = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
var third = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
var fourth = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(first.Success);
Assert.True(second.Success);
Assert.True(third.Success);
Assert.True(fourth.Success);
Assert.Equal(0, fourth.TargetRemainingHp);
Assert.True(fourth.TargetDefeated);
}
[Fact]
public void TryResolve_OnDefeatedTarget_ShouldDenyWithTargetDefeated()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
for (var i = 0; i < 4; i++)
{
_ = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
}
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.TargetDefeated, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Equal(0, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
Assert.Equal(0, snapshot.CurrentHp);
Assert.True(snapshot.Defeated);
}
[Fact]
public void TryResolve_WithGuardOnLiveTarget_ShouldSucceedWithZeroDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeGuard,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolve_WithGuardOnDefeatedTarget_ShouldDenyWithTargetDefeated()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeGuard,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.TargetDefeated, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
}
[Fact]
public void TryResolve_WithUnknownAbility_ShouldDenyWithUnknownAbility()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
"not_a_real_ability",
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.UnknownAbility, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Null(result.TargetRemainingHp);
}
[Fact]
public void TryResolve_WithUnknownTarget_ShouldDenyWithUnknownTarget()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
"not_a_target",
registry,
store);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.UnknownTarget, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Null(result.TargetRemainingHp);
}
[Fact]
public void TryResolve_WithBurstOnFreshAlpha_ShouldDealFortyDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeBurst,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Equal(40, result.DamageDealt);
Assert.Equal(60, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
}
[Fact]
public async Task Host_ShouldResolveCombatDependenciesFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var registry = factory.Services.GetRequiredService<IAbilityDefinitionRegistry>();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Equal(25, result.DamageDealt);
Assert.Equal(75, result.TargetRemainingHp);
}
}

View File

@ -0,0 +1,65 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Orchestrates deterministic combat resolution: ability catalog damage + prototype target HP (NEO-81).
/// Cast HTTP wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-82).
/// </summary>
public static class CombatOperations
{
/// <summary>
/// Resolves one ability against one prototype combat target using catalog damage and the health store.
/// Defeated targets deny before damage application; zero-damage abilities succeed without mutating HP.
/// </summary>
public static CombatResult TryResolve(
string abilityId,
string targetId,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore healthStore)
{
if (!abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility) ||
!abilityRegistry.TryGetDefinition(normalizedAbility, out var definition))
{
return Deny(CombatReasonCodes.UnknownAbility);
}
if (!healthStore.TryGet(targetId, out var snapshot))
{
return Deny(CombatReasonCodes.UnknownTarget);
}
if (snapshot.Defeated)
{
return Deny(CombatReasonCodes.TargetDefeated, snapshot.CurrentHp);
}
if (definition.BaseDamage == 0)
{
return new CombatResult(
Success: true,
ReasonCode: null,
DamageDealt: 0,
TargetRemainingHp: snapshot.CurrentHp,
TargetDefeated: snapshot.Defeated);
}
if (!healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after))
{
return Deny(CombatReasonCodes.UnknownTarget);
}
return new CombatResult(
Success: true,
ReasonCode: null,
DamageDealt: definition.BaseDamage,
TargetRemainingHp: after.CurrentHp,
TargetDefeated: after.Defeated);
}
private static CombatResult Deny(string reasonCode, int? targetRemainingHp = null) =>
new(
Success: false,
ReasonCode: reasonCode,
DamageDealt: 0,
TargetRemainingHp: targetRemainingHp,
TargetDefeated: false);
}

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Targeting;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Stable deny reason codes for combat operations (NEO-81).</summary>
public static class CombatReasonCodes
{
/// <summary>Target HP is already zero before resolve; no damage applied.</summary>
public const string TargetDefeated = "target_defeated";
/// <summary>Passthrough from <see cref="AbilityCastApi.ReasonUnknownAbility"/>.</summary>
public const string UnknownAbility = AbilityCastApi.ReasonUnknownAbility;
/// <summary>Passthrough from <see cref="TargetingApi.ReasonUnknownTarget"/>.</summary>
public const string UnknownTarget = TargetingApi.ReasonUnknownTarget;
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Server-internal combat resolution envelope (NEO-81); promoted to wire JSON via cast response extension (NEO-82).
/// </summary>
/// <param name="DamageDealt">Catalog damage applied on success; zero on deny.</param>
/// <param name="TargetRemainingHp">Authoritative HP after success; optional on deny (e.g. zero on <c>target_defeated</c>).</param>
/// <param name="TargetDefeated"><c>true</c> when post-resolve HP is zero on success; <c>false</c> on deny.</param>
public readonly record struct CombatResult(
bool Success,
string? ReasonCode,
int DamageDealt,
int? TargetRemainingHp,
bool TargetDefeated);

View File

@ -114,6 +114,20 @@ Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`I
Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md).
## 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. Cast HTTP wiring and wire **`CombatResolution`** fields are **NEO-82**.
| Reason code | When |
|-------------|------|
| **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). |
| **`unknown_target`** | **`targetId`** not in **`PrototypeTargetRegistry`** (health store gate). |
| **`target_defeated`** | Target HP is already **0** before resolve; no damage applied (includes zero-damage utility). |
**`CombatResult`** fields: **`success`**, **`reasonCode`** (`null` on success), **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**. On **`target_defeated`** deny, **`targetRemainingHp`** is **0** and **`targetDefeated`** on the envelope is **`false`** — use **`reasonCode`** for deny semantics.
Plan: [NEO-81 implementation plan](../../docs/plans/NEO-81-implementation-plan.md); cast wiring: [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82).
## Recipe definitions (NEO-68)
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.