diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index 0d3194c..af6f307 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -40,9 +40,15 @@ tests { expect(res.getStatus()).to.equal(200); }); - test("accepted true", function () { + test("accepted true with combat resolution", function () { const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.accepted).to.equal(true); + expect(body.combatResolution).to.be.an("object"); + expect(body.combatResolution.abilityId).to.equal("prototype_pulse"); + expect(body.combatResolution.targetId).to.equal("prototype_target_alpha"); + expect(body.combatResolution.damageDealt).to.equal(25); + expect(body.combatResolution.targetRemainingHp).to.equal(75); + expect(body.combatResolution.targetDefeated).to.equal(false); }); } diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru new file mode 100644 index 0000000..c92ca1f --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -0,0 +1,96 @@ +meta { + name: POST cast lock pulse defeat spine (NEO-82) + type: http + seq: 23 +} + +docs { + NEO-82 spine: pre-request moves, locks alpha, casts prototype_pulse four times (3s catalog cooldown between), then this request is the fifth cast expecting target_defeated deny. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_target_alpha" }, + jsonHeaders, + ); + + const castBody = { + schemaVersion: 1, + slotIndex: 0, + abilityId: "prototype_pulse", + targetId: "prototype_target_alpha", + }; + + for (let i = 0; i < 4; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + if (i < 3) { + await sleep(3100); + } + if (i === 3) { + bru.setVar("neo82FourthCast", JSON.stringify(response.data)); + } + } + + await sleep(3100); +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/ability-cast + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "slotIndex": 0, + "abilityId": "prototype_pulse", + "targetId": "prototype_target_alpha" + } +} + +tests { + test("fourth cast defeated target", function () { + const fourth = JSON.parse(bru.getVar("neo82FourthCast")); + expect(fourth.accepted).to.equal(true); + expect(fourth.combatResolution.targetRemainingHp).to.equal(0); + expect(fourth.combatResolution.targetDefeated).to.equal(true); + }); + + test("fifth cast denies target_defeated", function () { + const body = res.getBody(); + expect(res.getStatus()).to.equal(200); + expect(body.accepted).to.equal(false); + expect(body.reasonCode).to.equal("target_defeated"); + expect(body.combatResolution).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/ability-cast/folder.bru b/bruno/neon-sprawl-server/ability-cast/folder.bru index 59b3b47..f596329 100644 --- a/bruno/neon-sprawl-server/ability-cast/folder.bru +++ b/bruno/neon-sprawl-server/ability-cast/folder.bru @@ -1,3 +1,7 @@ meta { name: ability-cast } + +docs { + NEO-82: happy cast asserts combatResolution; defeat spine (seq 23) requires ~10s pre-request for pulse cooldowns between four lethal casts. +} diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index b8b7637..cb957e4 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -90,7 +90,7 @@ The **first shipped four-ability combat spine** under `content/abilities/*.json` ## Prototype notes (NEO-28) -Epic 1 **Slice 3** exercises a minimal cast accept/deny surface on **`POST /game/players/{id}/ability-cast`** before the full engine exists: after hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), the server requires **`targetId`** to match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`** for vocabulary alignment. **`accepted: true`** still does not imply damage, cooldown commit, or a full **`CombatResolution`** payload — those remain E5.M1 scope. +Epic 1 **Slice 3** cast accept/deny on **`POST /game/players/{id}/ability-cast`** remains the E1.M4 gate funnel: hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), **`targetId`** must match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`**. **NEO-82 (E5M1-07):** after gates pass, **`CombatOperations.TryResolve`** runs; accept returns nested wire **`combatResolution`** (`damageDealt`, `targetRemainingHp`, `targetDefeated`, …) and commits per-ability catalog cooldown; **`target_defeated`** JSON deny on re-hit at zero HP. ## Risks and telemetry diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index 352514a..0b305b1 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -236,9 +236,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/ **Acceptance criteria** -- [ ] Accept path returns non-empty resolution payload with deterministic damage. -- [ ] Cooldown duration matches ability catalog, not global constant. -- [ ] Bruno documents lock → cast → damage → defeat deny spine. +- [x] Accept path returns non-empty resolution payload with deterministic damage. +- [x] Cooldown duration matches ability catalog, not global constant. +- [x] Bruno documents lock → cast → damage → defeat deny spine. + +**Landed ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)):** cast POST invokes **`CombatOperations.TryResolve`**; nested **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** deny ([NEO-82-implementation-plan.md](NEO-82-implementation-plan.md)); Bruno defeat spine. **Client counterpart:** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) diff --git a/docs/plans/NEO-82-implementation-plan.md b/docs/plans/NEO-82-implementation-plan.md index 1f7f040..1667124 100644 --- a/docs/plans/NEO-82-implementation-plan.md +++ b/docs/plans/NEO-82-implementation-plan.md @@ -46,9 +46,9 @@ ## Acceptance criteria checklist -- [ ] Accept path returns non-empty **`combatResolution`** payload with deterministic catalog damage. -- [ ] Cooldown duration matches ability catalog (**`cooldownSeconds`**), not **`AbilityPrototypeCooldown.GlobalDuration`**. -- [ ] Bruno documents lock → cast → damage → defeat deny spine. +- [x] Accept path returns non-empty **`combatResolution`** payload with deterministic catalog damage. +- [x] Cooldown duration matches ability catalog (**`cooldownSeconds`**), not **`AbilityPrototypeCooldown.GlobalDuration`**. +- [x] Bruno documents lock → cast → damage → defeat deny spine. ## Technical approach @@ -135,3 +135,10 @@ Bruno spine covers manual verification; no **`docs/manual-qa/NEO-82.md`** (serve - Per-ability **`cooldownSeconds`** commit only on successful **`CombatOperations`** resolve. - **`target_defeated`** JSON deny on defeated re-hit; no cooldown. - No manual QA doc; Bruno + README sufficient (no client-facing change — NEO-85 owns HUD). + +## Reconciliation (implementation) + +- **`CombatResolutionJson`** + extended **`AbilityCastResponse`** in **`AbilityCastDtos.cs`**. +- **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`**; per-ability catalog cooldown on success; **`target_defeated`** deny without cooldown. +- **4** new AAA tests in **`AbilityCastApiTests`** (resolution, zero-damage, defeat chain, catalog cooldown); existing tests updated for resolution payload. +- Bruno happy + defeat spine; **`server/README.md`**, E5M1 backlog, E5_M1 module doc updated. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 9787621..771ba00 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using System.Text; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using Xunit; @@ -44,6 +45,27 @@ public sealed class AbilityCastApiTests Assert.True(body!.SelectionApplied); } + private static async Task BindSlot0GuardAsync(HttpClient client) + { + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard }], + }); + post.EnsureSuccessStatusCode(); + } + + private static AbilityCastRequest PulseCastRequest() => + new() + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) { var response = await client.PostAsJsonAsync( @@ -82,6 +104,12 @@ public sealed class AbilityCastApiTests Assert.NotNull(body); Assert.True(body!.Accepted); Assert.Null(body.ReasonCode); + Assert.NotNull(body.CombatResolution); + Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.CombatResolution.TargetId); + Assert.Equal(25, body.CombatResolution.DamageDealt); + Assert.Equal(75, body.CombatResolution.TargetRemainingHp); + Assert.False(body.CombatResolution.TargetDefeated); } [Fact] @@ -467,7 +495,7 @@ public sealed class AbilityCastApiTests // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); - factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50)); + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert @@ -479,5 +507,111 @@ public sealed class AbilityCastApiTests Assert.NotNull(body2); Assert.True(body1!.Accepted); Assert.True(body2!.Accepted); + Assert.NotNull(body2.CombatResolution); + Assert.Equal(50, body2.CombatResolution!.TargetRemainingHp); + } + + [Fact] + public async Task PostAbilityCast_ShouldReturnCombatResolution_WhenZeroDamageUtilityAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0GuardAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypeGuard, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + + // Act + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + + // Assert + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.True(body!.Accepted); + Assert.NotNull(body.CombatResolution); + Assert.Equal(0, body.CombatResolution!.DamageDealt); + Assert.Equal(100, body.CombatResolution.TargetRemainingHp); + Assert.False(body.CombatResolution.TargetDefeated); + } + + [Fact] + public async Task PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = PulseCastRequest(); + + // Act + AbilityCastResponse? fourth = null; + for (var i = 0; i < 4; i++) + { + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + fourth = await response.Content.ReadFromJsonAsync(); + response.EnsureSuccessStatusCode(); + if (i < 3) + { + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + } + + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + + // Assert + Assert.NotNull(fourth); + Assert.True(fourth!.Accepted); + Assert.NotNull(fourth.CombatResolution); + Assert.Equal(0, fourth.CombatResolution!.TargetRemainingHp); + Assert.True(fourth.CombatResolution.TargetDefeated); + var fifth = await fifthResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(fifth); + Assert.False(fifth!.Accepted); + Assert.Equal(CombatReasonCodes.TargetDefeated, fifth.ReasonCode); + Assert.Null(fifth.CombatResolution); + } + + [Fact] + public async Task PostAbilityCast_ShouldUseCatalogCooldown_WhenGuardCooldownLongerThanPulse() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0GuardAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypeGuard, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + + // Act + var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3.5)); + var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3)); + var third = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + + // Assert + var body1 = await first.Content.ReadFromJsonAsync(); + var body2 = await second.Content.ReadFromJsonAsync(); + var body3 = await third.Content.ReadFromJsonAsync(); + Assert.True(body1!.Accepted); + Assert.False(body2!.Accepted); + Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode); + Assert.True(body3!.Accepted); } } diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs index 2950f84..c190225 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs @@ -87,7 +87,7 @@ public sealed class CooldownSnapshotApiTests // Act var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); var snapDuring = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot"); - factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50)); + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var snapAfter = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot"); // Assert diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 763c996..075ec64 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -13,7 +13,8 @@ namespace NeonSprawl.Server.Game.AbilityInput; /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). /// Optional dev stdout: set NEON_SPRAWL_API_LOG=1 (see ) for one line per JSON cast response. /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON AbilityCastResponse). -/// NEO-32: JSON deny on_cooldown when the slot is inside the prototype global cooldown window. +/// NEO-32: JSON deny on_cooldown when the slot is inside the server cooldown window. +/// NEO-82: after E1 gates pass, invokes ; per-ability catalog cooldown on successful resolve. /// public static class AbilityCastApi { @@ -59,6 +60,7 @@ public static class AbilityCastApi IPlayerTargetLockStore locks, IPlayerAbilityCooldownStore cooldowns, IAbilityDefinitionRegistry abilities, + ICombatEntityHealthStore healthStore, TimeProvider clock) => { // NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body. @@ -182,17 +184,73 @@ public static class AbilityCastApi new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown }); } - cooldowns.StartCooldown(id, body.SlotIndex, now, AbilityPrototypeCooldown.GlobalDuration); + var combatResult = CombatOperations.TryResolve( + normalizedRequest, + lookupKey, + abilities, + healthStore); + + if (!combatResult.Success) + { + // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). + return JsonAbilityCast( + id, + body, + new AbilityCastResponse + { + Accepted = false, + ReasonCode = combatResult.ReasonCode, + }, + normalizedRequest); + } + + if (!abilities.TryGetDefinition(normalizedRequest, out var definition)) + { + return JsonAbilityCast( + id, + body, + new AbilityCastResponse + { + Accepted = false, + ReasonCode = ReasonUnknownAbility, + }, + normalizedRequest); + } + + cooldowns.StartCooldown( + id, + body.SlotIndex, + now, + TimeSpan.FromSeconds(definition.CooldownSeconds)); // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). return JsonAbilityCast( id, body, - new AbilityCastResponse { Accepted = true }, + MapAcceptResponse(normalizedRequest, lookupKey, combatResult), normalizedRequest); }); return app; } + + internal static AbilityCastResponse MapAcceptResponse( + string normalizedAbilityId, + string normalizedTargetId, + CombatResult result) + { + return new AbilityCastResponse + { + Accepted = true, + CombatResolution = new CombatResolutionJson + { + AbilityId = normalizedAbilityId, + TargetId = normalizedTargetId, + DamageDealt = result.DamageDealt, + TargetRemainingHp = result.TargetRemainingHp!.Value, + TargetDefeated = result.TargetDefeated, + }, + }; + } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs index e34d56c..0932295 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs @@ -24,6 +24,25 @@ public sealed class AbilityCastRequest public string? TargetId { get; init; } } +/// Combat outcome block on successful cast accept (NEO-82). +public sealed class CombatResolutionJson +{ + [JsonPropertyName("abilityId")] + public required string AbilityId { get; init; } + + [JsonPropertyName("targetId")] + public required string TargetId { get; init; } + + [JsonPropertyName("damageDealt")] + public int DamageDealt { get; init; } + + [JsonPropertyName("targetRemainingHp")] + public int TargetRemainingHp { get; init; } + + [JsonPropertyName("targetDefeated")] + public bool TargetDefeated { get; init; } +} + /// POST response for cast submit (prototype accept/deny; NEO-31 + NEO-28 target rules). public sealed class AbilityCastResponse { @@ -38,4 +57,9 @@ public sealed class AbilityCastResponse [JsonPropertyName("reasonCode")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ReasonCode { get; init; } + + /// Present only when is true after combat resolve (NEO-82). + [JsonPropertyName("combatResolution")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CombatResolutionJson? CombatResolution { get; init; } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs index aa91066..1e18869 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs @@ -1,8 +1,9 @@ namespace NeonSprawl.Server.Game.AbilityInput; -/// Prototype global cooldown applied on successful cast (NEO-32) until E5.M1 ability catalog. +/// Legacy prototype global cooldown constant (NEO-32); superseded by per-ability catalog durations (NEO-82). public static class AbilityPrototypeCooldown { - /// Single duration for every known prototype ability on accept. + /// Former global duration; kept for historical test/doc references — cast uses catalog cooldownSeconds. + [Obsolete("Use ability catalog cooldownSeconds on successful combat resolve (NEO-82).")] public static readonly TimeSpan GlobalDuration = TimeSpan.FromSeconds(3); } diff --git a/server/README.md b/server/README.md index 63d5102..5efec5a 100644 --- a/server/README.md +++ b/server/README.md @@ -116,7 +116,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m ## 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**. +**`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. | Reason code | When | |-------------|------| @@ -545,11 +545,21 @@ Prototype server-owned hotbar bindings are available at: Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`. -## Ability cast (NEO-31) +## Ability cast (NEO-31, NEO-82) -Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. +Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. -- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling; on accept, starts the prototype global cooldown for that slot. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`). +- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**. + +**Accept payload (`combatResolution`, present only when `accepted: true`):** + +| Field | Meaning | +|-------|---------| +| `abilityId` | Normalized ability id (matches request/binding). | +| `targetId` | Normalized prototype target id (lowercase registry key). | +| `damageDealt` | Catalog **`baseDamage`** applied (0 for utility abilities). | +| `targetRemainingHp` | Authoritative HP after resolve. | +| `targetDefeated` | `true` when post-resolve HP is zero. | **HTTP status:** @@ -569,13 +579,14 @@ Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** | `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). | | `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). | | `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). | -| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful accept (NEO-32). | +| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful combat resolve (NEO-32). | +| `target_defeated` | Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. | Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above). ## Cooldown snapshot (NEO-32) -Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **Global duration** `AbilityPrototypeCooldown.GlobalDuration` applies on each successful cast after all NEO-28 gates pass. +Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **NEO-82:** duration comes from ability catalog **`cooldownSeconds`** on each successful combat resolve (e.g. **`prototype_pulse`** 3.0s, **`prototype_guard`** 6.0s). - **`GET /game/players/{id}/cooldown-snapshot`** → `CooldownSnapshotResponse` v1 (`schemaVersion`, `playerId`, `serverTimeUtc`, `slots[]` with `slotIndex` and optional `cooldownEndsAtUtc` per slot). **404** when the player id is unknown to position state (same rule as loadout GET).