NEO-82: Wire cast POST into combat engine and cast response.
Invoke CombatOperations.TryResolve after E1 gates; nested combatResolution on accept; per-ability catalog cooldown; target_defeated deny without cooldown. Tests, Bruno defeat spine, README and module docs updated.pull/116/head
parent
4126ac9bcc
commit
e8be100f80
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<AbilityCastResponse>();
|
||||
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<AbilityCastResponse>();
|
||||
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<AbilityCastResponse>();
|
||||
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<AbilityCastResponse>();
|
||||
var body2 = await second.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
var body3 = await third.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.True(body1!.Accepted);
|
||||
Assert.False(body2!.Accepted);
|
||||
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
|
||||
Assert.True(body3!.Accepted);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <c>NEON_SPRAWL_API_LOG=1</c> (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>) for one line per JSON cast response.
|
||||
/// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>).
|
||||
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the prototype global cooldown window.
|
||||
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
|
||||
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
|
||||
/// </remarks>
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,25 @@ public sealed class AbilityCastRequest
|
|||
public string? TargetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Combat outcome block on successful cast accept (NEO-82).</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>POST response for cast submit (prototype accept/deny; NEO-31 + NEO-28 target rules).</summary>
|
||||
public sealed class AbilityCastResponse
|
||||
{
|
||||
|
|
@ -38,4 +57,9 @@ public sealed class AbilityCastResponse
|
|||
[JsonPropertyName("reasonCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonCode { get; init; }
|
||||
|
||||
/// <summary>Present only when <see cref="Accepted"/> is true after combat resolve (NEO-82).</summary>
|
||||
[JsonPropertyName("combatResolution")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public CombatResolutionJson? CombatResolution { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Prototype global cooldown applied on successful cast (NEO-32) until E5.M1 ability catalog.</summary>
|
||||
/// <summary>Legacy prototype global cooldown constant (NEO-32); superseded by per-ability catalog durations (NEO-82).</summary>
|
||||
public static class AbilityPrototypeCooldown
|
||||
{
|
||||
/// <summary>Single duration for every known prototype ability on accept.</summary>
|
||||
/// <summary>Former global duration; kept for historical test/doc references — cast uses catalog <c>cooldownSeconds</c>.</summary>
|
||||
[Obsolete("Use ability catalog cooldownSeconds on successful combat resolve (NEO-82).")]
|
||||
public static readonly TimeSpan GlobalDuration = TimeSpan.FromSeconds(3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue