diff --git a/docs/plans/NEO-81-implementation-plan.md b/docs/plans/NEO-81-implementation-plan.md index 619a754..a39b897 100644 --- a/docs/plans/NEO-81-implementation-plan.md +++ b/docs/plans/NEO-81-implementation-plan.md @@ -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. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs new file mode 100644 index 0000000..f4fddcc --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs @@ -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(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(); + var store = factory.Services.GetRequiredService(); + // 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatOperations.cs b/server/NeonSprawl.Server/Game/Combat/CombatOperations.cs new file mode 100644 index 0000000..8df78e3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatOperations.cs @@ -0,0 +1,65 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Orchestrates deterministic combat resolution: ability catalog damage + prototype target HP (NEO-81). +/// Cast HTTP wiring: (NEO-82). +/// +public static class CombatOperations +{ + /// + /// 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. + /// + 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); +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs b/server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs new file mode 100644 index 0000000..3fc85fc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Targeting; + +namespace NeonSprawl.Server.Game.Combat; + +/// Stable deny reason codes for combat operations (NEO-81). +public static class CombatReasonCodes +{ + /// Target HP is already zero before resolve; no damage applied. + public const string TargetDefeated = "target_defeated"; + + /// Passthrough from . + public const string UnknownAbility = AbilityCastApi.ReasonUnknownAbility; + + /// Passthrough from . + public const string UnknownTarget = TargetingApi.ReasonUnknownTarget; +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatResult.cs b/server/NeonSprawl.Server/Game/Combat/CombatResult.cs new file mode 100644 index 0000000..5805604 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatResult.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Server-internal combat resolution envelope (NEO-81); promoted to wire JSON via cast response extension (NEO-82). +/// +/// Catalog damage applied on success; zero on deny. +/// Authoritative HP after success; optional on deny (e.g. zero on target_defeated). +/// true when post-resolve HP is zero on success; false on deny. +public readonly record struct CombatResult( + bool Success, + string? ReasonCode, + int DamageDealt, + int? TargetRemainingHp, + bool TargetDefeated); diff --git a/server/README.md b/server/README.md index 5482432..63d5102 100644 --- a/server/README.md +++ b/server/README.md @@ -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/`.