NEO-108: Add GET encounter-progress API and integration tests.
Projects progress, completion, and event stores into versioned per-player rows with inactive/active/completed state and commit-time grant summary.pull/147/head
parent
16018586b7
commit
c1fd2053d0
|
|
@ -0,0 +1,251 @@
|
|||
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_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.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");
|
||||
|
||||
// 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.StateCompleted, row.State);
|
||||
Assert.Equal(
|
||||
[
|
||||
PrototypeNpcRegistry.PrototypeNpcEliteId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
],
|
||||
row.DefeatedTargetIds);
|
||||
Assert.NotNull(row.CompletedAt);
|
||||
Assert.NotNull(row.RewardGrantSummary);
|
||||
Assert.Equal(10, row.RewardGrantSummary!.Single(g => g.ItemId == "scrap_metal_bulk").Quantity);
|
||||
Assert.Equal(1, row.RewardGrantSummary.Single(g => g.ItemId == "contract_handoff_token").Quantity);
|
||||
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 _));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Maps <c>GET /game/players/{{id}}/encounter-progress</c> (NEO-108).</summary>
|
||||
public static class EncounterProgressApi
|
||||
{
|
||||
public const string StateInactive = "inactive";
|
||||
public const string StateActive = "active";
|
||||
public const string StateCompleted = "completed";
|
||||
|
||||
public static WebApplication MapEncounterProgressApi(this WebApplication app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/game/players/{id}/encounter-progress",
|
||||
(string id, IPositionStateStore positions, IEncounterDefinitionRegistry encounterRegistry,
|
||||
IEncounterProgressStore progressStore, IEncounterCompletionStore completionStore,
|
||||
IEncounterCompleteEventStore completeEventStore) =>
|
||||
{
|
||||
var trimmedId = id.Trim();
|
||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(
|
||||
BuildSnapshot(
|
||||
trimmedId,
|
||||
encounterRegistry,
|
||||
progressStore,
|
||||
completionStore,
|
||||
completeEventStore));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static EncounterProgressListResponse BuildSnapshot(
|
||||
string playerId,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IEncounterProgressStore progressStore,
|
||||
IEncounterCompletionStore completionStore,
|
||||
IEncounterCompleteEventStore completeEventStore)
|
||||
{
|
||||
var defs = encounterRegistry.GetDefinitionsInIdOrder();
|
||||
var rows = new List<EncounterProgressRowJson>(defs.Count);
|
||||
foreach (var def in defs)
|
||||
{
|
||||
rows.Add(
|
||||
MapRow(
|
||||
playerId,
|
||||
def.Id,
|
||||
progressStore,
|
||||
completionStore,
|
||||
completeEventStore));
|
||||
}
|
||||
|
||||
return new EncounterProgressListResponse
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Encounters = rows,
|
||||
SchemaVersion = EncounterProgressListResponse.CurrentSchemaVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private static EncounterProgressRowJson MapRow(
|
||||
string playerId,
|
||||
string encounterId,
|
||||
IEncounterProgressStore progressStore,
|
||||
IEncounterCompletionStore completionStore,
|
||||
IEncounterCompleteEventStore completeEventStore)
|
||||
{
|
||||
var defeatedTargetIds = Array.Empty<string>();
|
||||
if (progressStore.TryGetProgress(playerId, encounterId, out var progress))
|
||||
{
|
||||
defeatedTargetIds = progress.DefeatedNpcInstanceIds
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (completionStore.IsCompleted(playerId, encounterId))
|
||||
{
|
||||
if (!completionStore.TryGetCompletedAt(playerId, encounterId, out var completedAt))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Encounter '{encounterId}' is completed for player '{playerId}' but completedAt is missing.");
|
||||
}
|
||||
|
||||
if (!completeEventStore.TryGet(playerId, encounterId, out var completeEvent))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Encounter '{encounterId}' is completed for player '{playerId}' but EncounterCompleteEvent is missing.");
|
||||
}
|
||||
|
||||
return new EncounterProgressRowJson
|
||||
{
|
||||
EncounterId = encounterId,
|
||||
State = StateCompleted,
|
||||
DefeatedTargetIds = defeatedTargetIds,
|
||||
CompletedAt = completedAt,
|
||||
RewardGrantSummary = MapGrantSummary(completeEvent.GrantedItems),
|
||||
};
|
||||
}
|
||||
|
||||
if (progressStore.TryGetProgress(playerId, encounterId, out progress) && progress.Started)
|
||||
{
|
||||
return new EncounterProgressRowJson
|
||||
{
|
||||
EncounterId = encounterId,
|
||||
State = StateActive,
|
||||
DefeatedTargetIds = defeatedTargetIds,
|
||||
};
|
||||
}
|
||||
|
||||
return new EncounterProgressRowJson
|
||||
{
|
||||
EncounterId = encounterId,
|
||||
State = StateInactive,
|
||||
DefeatedTargetIds = defeatedTargetIds,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<EncounterRewardGrantJson> MapGrantSummary(IReadOnlyList<EncounterGrantApplied> grants)
|
||||
{
|
||||
var summary = new List<EncounterRewardGrantJson>(grants.Count);
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
summary.Add(
|
||||
new EncounterRewardGrantJson
|
||||
{
|
||||
ItemId = grant.ItemId,
|
||||
Quantity = grant.Quantity,
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/players/{{id}}/encounter-progress</c> (NEO-108).</summary>
|
||||
public sealed class EncounterProgressListResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("playerId")]
|
||||
public required string PlayerId { get; init; }
|
||||
|
||||
/// <summary>Per-player rows ordered by catalog encounter <c>id</c> (ordinal).</summary>
|
||||
[JsonPropertyName("encounters")]
|
||||
public required IReadOnlyList<EncounterProgressRowJson> Encounters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Authoritative per-player progress for one encounter definition.</summary>
|
||||
public sealed class EncounterProgressRowJson
|
||||
{
|
||||
[JsonPropertyName("encounterId")]
|
||||
public required string EncounterId { get; init; }
|
||||
|
||||
/// <summary><c>inactive</c>, <c>active</c>, or <c>completed</c>.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
|
||||
[JsonPropertyName("defeatedTargetIds")]
|
||||
public required IReadOnlyList<string> DefeatedTargetIds { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("rewardGrantSummary")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<EncounterRewardGrantJson>? RewardGrantSummary { get; init; }
|
||||
}
|
||||
|
|
@ -71,6 +71,7 @@ app.MapPlayerInventoryApi();
|
|||
app.MapPlayerCraftApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapEncounterProgressApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue