962 lines
39 KiB
C#
962 lines
39 KiB
C#
using NeonSprawl.Server.Game.AbilityInput;
|
|
using NeonSprawl.Server.Game.Combat;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Gigs;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.PositionState;
|
|
using NeonSprawl.Server.Game.Targeting;
|
|
|
|
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 Task LockPrototypeTargetAlphaAsync(HttpClient client) =>
|
|
LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
|
|
private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId)
|
|
{
|
|
var response = await client.PostAsJsonAsync(
|
|
"/game/players/dev-local-1/target/select",
|
|
new TargetSelectRequest
|
|
{
|
|
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
|
TargetId = targetId,
|
|
});
|
|
response.EnsureSuccessStatusCode();
|
|
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.True(body!.SelectionApplied);
|
|
}
|
|
|
|
private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId)
|
|
{
|
|
Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry));
|
|
var response = await client.PostAsJsonAsync(
|
|
"/game/players/dev-local-1/move",
|
|
new MoveCommandRequest
|
|
{
|
|
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
|
Target = new PositionVector
|
|
{
|
|
X = entry.X - 1,
|
|
Y = 0.9,
|
|
Z = entry.Z - 1,
|
|
},
|
|
});
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
private static AbilityCastRequest PulseCastRequestForTarget(string targetId) =>
|
|
new()
|
|
{
|
|
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
|
SlotIndex = 0,
|
|
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
|
TargetId = targetId,
|
|
};
|
|
|
|
private static async Task DefeatLockedTargetWithPulseAsync(
|
|
HttpClient client,
|
|
InMemoryWebApplicationFactory factory,
|
|
string targetId)
|
|
{
|
|
Assert.NotNull(factory.FakeClock);
|
|
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
|
await BindSlot0PulseAsync(client);
|
|
await MoveDevPlayerNearNpcAsync(client, targetId);
|
|
await LockPrototypeTargetAsync(client, targetId);
|
|
var cast = PulseCastRequestForTarget(targetId);
|
|
AbilityCastResponse? defeatBody = null;
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
|
response.EnsureSuccessStatusCode();
|
|
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.True(body!.Accepted, body.ReasonCode);
|
|
Assert.NotNull(body.CombatResolution);
|
|
if (body.CombatResolution!.TargetDefeated)
|
|
{
|
|
defeatBody = body;
|
|
break;
|
|
}
|
|
|
|
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
|
}
|
|
|
|
Assert.NotNull(defeatBody);
|
|
Assert.True(defeatBody!.CombatResolution!.TargetDefeated);
|
|
}
|
|
|
|
private static int CountBagItem(PlayerInventorySnapshotResponse snapshot, string itemId)
|
|
{
|
|
var total = 0;
|
|
foreach (var slot in snapshot.BagSlots)
|
|
{
|
|
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
|
{
|
|
total += slot.Quantity;
|
|
}
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
};
|
|
|
|
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
});
|
|
|
|
// 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(PrototypeNpcRegistry.PrototypeNpcMeleeId, 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_Npc_Melee ",
|
|
});
|
|
|
|
// 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 = PrototypeNpcRegistry.PrototypeNpcRangedId,
|
|
});
|
|
|
|
// 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
});
|
|
|
|
// 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
});
|
|
|
|
// 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_ShouldStartCatalogCooldown_WhenResolveSucceedsAfterDefinitionGate()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
await BindSlot0PulseAsync(client);
|
|
await LockPrototypeTargetAlphaAsync(client);
|
|
var cast = PulseCastRequest();
|
|
|
|
// Act
|
|
var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
|
var snapshotResponse = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
|
|
|
|
// Assert
|
|
var body = await castResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
|
Assert.Equal(HttpStatusCode.OK, castResponse.StatusCode);
|
|
Assert.NotNull(body);
|
|
Assert.True(body!.Accepted);
|
|
Assert.NotNull(body.CombatResolution);
|
|
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<CooldownSnapshotResponse>();
|
|
Assert.NotNull(snapshot);
|
|
var slot0 = snapshot!.Slots.Single(s => s.SlotIndex == 0);
|
|
Assert.NotNull(slot0.CooldownEndsAtUtc);
|
|
}
|
|
|
|
[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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
};
|
|
|
|
// 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
};
|
|
|
|
// 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
};
|
|
|
|
// 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);
|
|
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
|
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
|
|
Assert.Null(threat.AggroHolderPlayerId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAbilityCast_ShouldSetAggroHolder_WhenDamagingCastAccepted()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
|
await BindSlot0PulseAsync(client);
|
|
await LockPrototypeTargetAlphaAsync(client);
|
|
|
|
// Act
|
|
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", PulseCastRequest());
|
|
response.EnsureSuccessStatusCode();
|
|
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
|
|
|
|
// Assert
|
|
Assert.Equal("dev-local-1", threat.AggroHolderPlayerId);
|
|
}
|
|
|
|
[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_ShouldStopNpcCombat_WhenTargetDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
await BindSlot0PulseAsync(client);
|
|
await LockPrototypeTargetAlphaAsync(client);
|
|
var cast = PulseCastRequest();
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
|
response.EnsureSuccessStatusCode();
|
|
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
|
}
|
|
|
|
// Act
|
|
var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
|
fourthResponse.EnsureSuccessStatusCode();
|
|
var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
|
factory.FakeClock.Advance(TimeSpan.FromSeconds(30));
|
|
_ = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
|
var healthAfterWaitResponse = await client.GetAsync("/game/players/dev-local-1/combat-health");
|
|
|
|
// Assert
|
|
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
|
Assert.NotNull(fourth);
|
|
Assert.True(fourth!.CombatResolution!.TargetDefeated);
|
|
snapshotResponse.EnsureSuccessStatusCode();
|
|
var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
|
|
Assert.NotNull(snapshotBody);
|
|
var melee = snapshotBody!.NpcInstances.Single(
|
|
row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
Assert.Equal("idle", melee.State);
|
|
Assert.Null(melee.AggroHolderPlayerId);
|
|
Assert.Null(melee.ActiveTelegraph);
|
|
healthAfterWaitResponse.EnsureSuccessStatusCode();
|
|
var healthBody = await healthAfterWaitResponse.Content.ReadFromJsonAsync<PlayerCombatHealthResponse>();
|
|
Assert.NotNull(healthBody);
|
|
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
await BindSlot0PulseAsync(client);
|
|
await LockPrototypeTargetAlphaAsync(client);
|
|
var skillBefore = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
|
|
"/game/players/dev-local-1/skill-progression");
|
|
Assert.NotNull(skillBefore);
|
|
var salvageBefore = skillBefore!.Skills!.Single(static s => s.Id == "salvage");
|
|
var refineBefore = skillBefore.Skills!.Single(static s => s.Id == "refine");
|
|
var cast = PulseCastRequest();
|
|
|
|
// Act
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
|
response.EnsureSuccessStatusCode();
|
|
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
|
}
|
|
|
|
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);
|
|
var gigSnapshot = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
|
|
"/game/players/dev-local-1/gig-progression");
|
|
var skillAfter = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
|
|
"/game/players/dev-local-1/skill-progression");
|
|
|
|
// Assert
|
|
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
|
Assert.NotNull(fourth);
|
|
Assert.True(fourth!.CombatResolution!.TargetDefeated);
|
|
var fifth = await fifthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
|
Assert.NotNull(fifth);
|
|
Assert.False(fifth!.Accepted);
|
|
Assert.NotNull(gigSnapshot);
|
|
var breach = Assert.Single(gigSnapshot!.Gigs!);
|
|
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, breach.Xp);
|
|
Assert.NotNull(skillAfter);
|
|
var salvageAfter = skillAfter!.Skills!.Single(static s => s.Id == "salvage");
|
|
var refineAfter = skillAfter.Skills!.Single(static s => s.Id == "refine");
|
|
Assert.Equal(salvageBefore.Xp, salvageAfter.Xp);
|
|
Assert.Equal(refineBefore.Xp, refineAfter.Xp);
|
|
}
|
|
|
|
[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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
|
"/game/players/dev-local-1/inventory");
|
|
|
|
// Act
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcEliteId);
|
|
|
|
// Assert
|
|
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
|
"/game/players/dev-local-1/inventory");
|
|
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
|
Assert.NotNull(inventoryBefore);
|
|
Assert.NotNull(inventoryAfter);
|
|
Assert.Equal(
|
|
CountBagItem(inventoryBefore!, "scrap_metal_bulk") + 10,
|
|
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
|
|
Assert.Equal(
|
|
CountBagItem(inventoryBefore!, "contract_handoff_token") + 1,
|
|
CountBagItem(inventoryAfter!, "contract_handoff_token"));
|
|
Assert.True(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
|
"/game/players/dev-local-1/inventory");
|
|
|
|
// Act
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId);
|
|
|
|
// Assert
|
|
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
|
"/game/players/dev-local-1/inventory");
|
|
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
|
Assert.NotNull(inventoryBefore);
|
|
Assert.NotNull(inventoryAfter);
|
|
Assert.Equal(
|
|
CountBagItem(inventoryBefore!, "scrap_metal_bulk"),
|
|
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
|
|
Assert.Equal(
|
|
CountBagItem(inventoryBefore!, "contract_handoff_token"),
|
|
CountBagItem(inventoryAfter!, "contract_handoff_token"));
|
|
Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAbilityCast_ShouldGrantGigXpPerDefeat_WhenAllThreeNpcDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
var gigBefore = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
|
|
"/game/players/dev-local-1/gig-progression");
|
|
|
|
// Act
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcEliteId);
|
|
var gigAfter = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
|
|
"/game/players/dev-local-1/gig-progression");
|
|
|
|
// Assert
|
|
Assert.NotNull(gigBefore);
|
|
Assert.NotNull(gigAfter);
|
|
var breachBefore = Assert.Single(gigBefore!.Gigs!);
|
|
var breachAfter = Assert.Single(gigAfter!.Gigs!);
|
|
Assert.Equal(0, breachBefore.Xp);
|
|
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount * 3, breachAfter.Xp);
|
|
}
|
|
}
|