364 lines
14 KiB
C#
364 lines
14 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NeonSprawl.Server.Game.AbilityInput;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.Npc;
|
|
using NeonSprawl.Server.Game.PositionState;
|
|
using NeonSprawl.Server.Game.Targeting;
|
|
using Xunit;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
|
|
|
public sealed class EncounterProgressApiTests
|
|
{
|
|
private const string PrototypeEncounterId = "prototype_combat_pocket";
|
|
|
|
[Fact]
|
|
public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerUnknown()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/game/players/missing-player/encounter-progress");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/game/players/%20/encounter-progress");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetEncounterProgress_ShouldReturnInactive_WhenNoEngagement()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.Equal(EncounterProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
|
Assert.Equal("dev-local-1", body.PlayerId);
|
|
var row = Assert.Single(body.Encounters);
|
|
Assert.Equal(PrototypeEncounterId, row.EncounterId);
|
|
Assert.Equal(EncounterProgressApi.StateInactive, row.State);
|
|
Assert.Empty(row.DefeatedTargetIds);
|
|
Assert.Null(row.CompletedAt);
|
|
Assert.Null(row.RewardGrantSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetEncounterProgress_ShouldReturnActiveWithOneDefeat_WhenFirstNpcDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
|
|
// Act
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
|
|
Assert.NotNull(body);
|
|
var row = Assert.Single(body!.Encounters);
|
|
Assert.Equal(EncounterProgressApi.StateActive, row.State);
|
|
Assert.Equal([PrototypeNpcRegistry.PrototypeNpcMeleeId], row.DefeatedTargetIds);
|
|
Assert.Null(row.CompletedAt);
|
|
Assert.Null(row.RewardGrantSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetEncounterProgress_ShouldReturnActiveWithTwoDefeats_WhenTwoNpcDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
|
|
// Act
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId);
|
|
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
|
|
Assert.NotNull(body);
|
|
var row = Assert.Single(body!.Encounters);
|
|
Assert.Equal(EncounterProgressApi.StateActive, row.State);
|
|
Assert.Equal(
|
|
[
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId,
|
|
],
|
|
row.DefeatedTargetIds);
|
|
Assert.True(row.DefeatedTargetIds.SequenceEqual(
|
|
row.DefeatedTargetIds.OrderBy(static id => id, StringComparer.Ordinal)));
|
|
Assert.Null(row.CompletedAt);
|
|
Assert.Null(row.RewardGrantSummary);
|
|
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
|
Assert.False(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetEncounterProgress_ShouldReturnCompletedWithGrantSummary_WhenAllThreeNpcDefeated()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
Assert.NotNull(factory.FakeClock);
|
|
|
|
// Act
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId);
|
|
await DefeatLockedTargetWithPulseAsync(
|
|
client,
|
|
factory,
|
|
PrototypeNpcRegistry.PrototypeNpcEliteId);
|
|
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
|
|
var secondResponse = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
|
|
var secondBody = await secondResponse.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.NotNull(secondBody);
|
|
var row = Assert.Single(body!.Encounters);
|
|
var secondRow = Assert.Single(secondBody!.Encounters);
|
|
Assert.Equal(EncounterProgressApi.StateCompleted, row.State);
|
|
Assert.Equal(EncounterProgressApi.StateCompleted, secondRow.State);
|
|
Assert.Equal(
|
|
[
|
|
PrototypeNpcRegistry.PrototypeNpcEliteId,
|
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
|
PrototypeNpcRegistry.PrototypeNpcRangedId,
|
|
],
|
|
row.DefeatedTargetIds);
|
|
Assert.Equal(row.DefeatedTargetIds, secondRow.DefeatedTargetIds);
|
|
Assert.NotNull(row.CompletedAt);
|
|
Assert.NotNull(row.RewardGrantSummary);
|
|
Assert.Equal(row.CompletedAt, secondRow.CompletedAt);
|
|
Assert.Equal(2, row.RewardGrantSummary!.Count);
|
|
Assert.Equal("scrap_metal_bulk", row.RewardGrantSummary[0].ItemId);
|
|
Assert.Equal(10, row.RewardGrantSummary[0].Quantity);
|
|
Assert.Equal("contract_handoff_token", row.RewardGrantSummary[1].ItemId);
|
|
Assert.Equal(1, row.RewardGrantSummary[1].Quantity);
|
|
Assert.Equal(row.RewardGrantSummary[0].ItemId, secondRow.RewardGrantSummary![0].ItemId);
|
|
Assert.Equal(row.RewardGrantSummary[1].ItemId, secondRow.RewardGrantSummary[1].ItemId);
|
|
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
|
var eventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
|
|
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId));
|
|
Assert.True(eventStore.TryGet("dev-local-1", PrototypeEncounterId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildSnapshot_ShouldThrow_WhenCompletedButCompleteEventMissing()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
|
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
|
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
|
var eventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
|
|
Assert.True(
|
|
completionStore.TryMarkCompleted(
|
|
"dev-local-1",
|
|
PrototypeEncounterId,
|
|
DateTimeOffset.UtcNow));
|
|
|
|
// Act
|
|
var ex = Record.Exception(
|
|
() => EncounterProgressApi.BuildSnapshot(
|
|
"dev-local-1",
|
|
registry,
|
|
progressStore,
|
|
completionStore,
|
|
eventStore));
|
|
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("EncounterCompleteEvent is missing", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildSnapshot_ShouldThrow_WhenCompletedButCompletedAtMissing()
|
|
{
|
|
// Arrange
|
|
var registry = CreatePrototypeEncounterRegistry();
|
|
var progressStore = new InMemoryEncounterProgressStore();
|
|
var completionStore = new CompletedWithoutTimestampStore();
|
|
var eventStore = new InMemoryEncounterCompleteEventStore();
|
|
|
|
// Act
|
|
var ex = Record.Exception(
|
|
() => EncounterProgressApi.BuildSnapshot(
|
|
"dev-local-1",
|
|
registry,
|
|
progressStore,
|
|
completionStore,
|
|
eventStore));
|
|
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("completedAt is missing", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static EncounterDefinitionRegistry CreatePrototypeEncounterRegistry()
|
|
{
|
|
var row = new EncounterDefRow(
|
|
PrototypeEncounterId,
|
|
"Prototype Combat Pocket",
|
|
"defeat_all_targets",
|
|
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
|
|
"prototype_combat_pocket_clear");
|
|
var catalog = new EncounterDefinitionCatalog(
|
|
"/tmp/encounters",
|
|
new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal) { [PrototypeEncounterId] = row },
|
|
catalogJsonFileCount: 1);
|
|
return new EncounterDefinitionRegistry(catalog);
|
|
}
|
|
|
|
private sealed class CompletedWithoutTimestampStore : IEncounterCompletionStore
|
|
{
|
|
public bool IsCompleted(string playerId, string encounterId) =>
|
|
string.Equals(playerId, "dev-local-1", StringComparison.Ordinal)
|
|
&& string.Equals(encounterId, PrototypeEncounterId, StringComparison.Ordinal);
|
|
|
|
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt)
|
|
{
|
|
completedAt = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|