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
parent
849c34f460
commit
ca15e7a689
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue