neon-sprawl/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs

637 lines
25 KiB
C#

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;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
/// <summary>HTTP integration tests for <see cref="AbilityCastApi"/>.</summary>
/// <remarks>
/// NEO-30: every JSON deny (<see cref="AbilityCastResponse.Accepted"/> <c>false</c>) asserts a non-whitespace
/// <see cref="AbilityCastResponse.ReasonCode"/> — matches Slice 3 <c>ability_cast_denied</c> payload expectations;
/// authoritative hook-site comments live on <see cref="AbilityCastApi"/> (cast route: <c>MapAbilityCastApi</c> extension in the same source file).
/// Optional dev: <c>NEON_SPRAWL_API_LOG=1</c> enables one-line <c>ability_cast_*</c> stdout per JSON cast response (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>).
/// </remarks>
public sealed class AbilityCastApiTests
{
private static async Task BindSlot0PulseAsync(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.PrototypePulse }],
});
post.EnsureSuccessStatusCode();
}
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
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(
"/game/players/dev-local-1/move-stream",
new MoveStreamRequest
{
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
Targets = [new PositionVector { X = x, Y = y, Z = z }],
});
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatchesAndLockInRange()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
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]
public async Task PostAbilityCast_ShouldAccept_WithTargetId_WhenLoadoutMatches()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = "prototype_target_alpha",
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.True(body!.Accepted);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetIdMissing()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = null,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenUnknownTargetId()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = "not_a_registered_combat_target",
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetDoesNotMatchServerLock()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenNoServerLock()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyOutOfRange_WhenAuthoritativePositionTooFarFromLock()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
await TeleportDevPlayerAsync(client, 50.0, 0.9, 50.0);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonOutOfRange, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenySlotUnbound_WhenSlotEmpty()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 2,
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
TargetId = null,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonSlotUnbound, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyLoadoutMismatch_WhenAbilityDoesNotMatchSlot()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypeBurst,
TargetId = null,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonLoadoutMismatch, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenySlotOutOfBounds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 8,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = null,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonSlotOutOfBounds, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyUnknownAbility_WhenAbilityIdNotInRegistry()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = "not_a_real_ability",
TargetId = null,
});
// Assert
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonUnknownAbility, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldReturnBadRequest_WhenBodyMalformed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var malformed = new StringContent(
"{\"schemaVersion\":1,\"slotIndex\":",
Encoding.UTF8,
"application/json");
// Act
var response = await client.PostAsync("/game/players/dev-local-1/ability-cast", malformed);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostAbilityCast_ShouldReturnBadRequest_WhenSchemaVersionWrong()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = new AbilityCastRequest
{
SchemaVersion = 999,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
};
// Act
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostAbilityCast_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
};
// Act
var response = await client.PostAsJsonAsync("/game/players/missing-player/ability-cast", request);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
};
// Act
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
var second = 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>();
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.NotNull(body1);
Assert.NotNull(body2);
Assert.True(body1!.Accepted);
Assert.False(body2!.Accepted);
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldAcceptAgain_WhenCooldownElapsed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
};
// Act
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
var second = 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>();
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.NotNull(body1);
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();
AbilityCastResponse? first = null;
AbilityCastResponse? second = null;
AbilityCastResponse? third = null;
for (var i = 0; i < 3; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
if (i == 0)
{
first = body;
}
else if (i == 1)
{
second = body;
}
else
{
third = body;
}
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
// Act
var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
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(first);
Assert.Equal(75, first!.CombatResolution!.TargetRemainingHp);
Assert.NotNull(second);
Assert.Equal(50, second!.CombatResolution!.TargetRemainingHp);
Assert.NotNull(third);
Assert.Equal(25, third!.CombatResolution!.TargetRemainingHp);
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
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);
}
}