407 lines
16 KiB
C#
407 lines
16 KiB
C#
using NeonSprawl.Server.Game.Contracts;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Factions;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.Mastery;
|
|
using NeonSprawl.Server.Game.Quests;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
using NeonSprawl.Server.Game.Skills;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
|
|
|
public sealed class ContractApiTests
|
|
{
|
|
private const string PlayerId = "dev-local-1";
|
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
|
private const string EncounterId = "prototype_combat_pocket";
|
|
private const string SeedBucket = "2026-06-28-neo151";
|
|
private const string DisplayName = "Clear Combat Pocket (Repeat)";
|
|
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
"/game/players/missing-player/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest("missing-player")));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
"/game/players/%20/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("", SeedBucket)]
|
|
[InlineData(" ", SeedBucket)]
|
|
[InlineData(TemplateId, "")]
|
|
[InlineData(TemplateId, " ")]
|
|
public async Task PostContractIssue_ShouldReturnBadRequest_WhenRequiredSeedFieldsEmpty(
|
|
string templateId,
|
|
string seedBucket)
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(
|
|
new ContractIssueRequest
|
|
{
|
|
SchemaVersion = ContractIssueRequest.CurrentSchemaVersion,
|
|
PlayerId = PlayerId,
|
|
TemplateId = templateId,
|
|
SeedBucket = seedBucket,
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest("other-player")));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(
|
|
new ContractIssueRequest
|
|
{
|
|
SchemaVersion = 99,
|
|
PlayerId = PlayerId,
|
|
TemplateId = TemplateId,
|
|
SeedBucket = SeedBucket,
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
|
PlayerId,
|
|
TemplateId,
|
|
SeedBucket);
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<ContractIssueResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.Equal(ContractIssueResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
|
Assert.True(body.Issued);
|
|
Assert.Null(body.ReasonCode);
|
|
Assert.NotNull(body.Contract);
|
|
Assert.Equal(expectedInstanceId, body.Contract!.ContractInstanceId);
|
|
Assert.Equal(TemplateId, body.Contract.TemplateId);
|
|
Assert.Equal(DisplayName, body.Contract.TemplateDisplayName);
|
|
Assert.Equal(ContractListApi.StatusActive, body.Contract.Status);
|
|
Assert.Equal(EncounterId, body.Contract.EncounterTemplateId);
|
|
Assert.Equal(SeedBucket, body.Contract.SeedBucket);
|
|
Assert.Null(body.Contract.CompletedAt);
|
|
Assert.Null(body.Contract.CompletionRewardSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostContractIssue_ShouldReturnIssuedFalse_WhenActiveContractExists()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
var first = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(
|
|
new ContractIssueRequest
|
|
{
|
|
SchemaVersion = ContractIssueRequest.CurrentSchemaVersion,
|
|
PlayerId = PlayerId,
|
|
TemplateId = TemplateId,
|
|
SeedBucket = "2026-06-28-neo151-second",
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<ContractIssueResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.False(body!.Issued);
|
|
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, body.ReasonCode);
|
|
Assert.NotNull(body.Contract);
|
|
Assert.Equal(ContractListApi.StatusActive, body.Contract!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetContracts_ShouldReturnNotFound_WhenPlayerUnknown()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/game/players/missing-player/contracts");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetContracts_ShouldReturnEmptyList_WhenNoInstances()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
|
|
Assert.NotNull(body);
|
|
Assert.Equal(ContractListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
|
Assert.Equal(PlayerId, body.PlayerId);
|
|
Assert.Empty(body.Contracts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetContracts_ShouldReturnActiveRowAfterIssue_WithEncounterObjectiveId()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
var issue = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
|
Assert.Equal(HttpStatusCode.OK, issue.StatusCode);
|
|
|
|
// Act
|
|
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
|
|
Assert.NotNull(body);
|
|
var row = Assert.Single(body!.Contracts);
|
|
Assert.Equal(ContractListApi.StatusActive, row.Status);
|
|
Assert.Equal(EncounterId, row.EncounterTemplateId);
|
|
Assert.Null(row.CompletionRewardSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
var deps = ResolveDependencies(factory);
|
|
var issue = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
|
Assert.Equal(HttpStatusCode.OK, issue.StatusCode);
|
|
var issueBody = await issue.Content.ReadFromJsonAsync<ContractIssueResponse>();
|
|
Assert.NotNull(issueBody?.Contract);
|
|
var instanceId = issueBody!.Contract!.ContractInstanceId;
|
|
var completed = ContractCompletionOperations.TryCompleteOnEncounterClear(
|
|
PlayerId,
|
|
EncounterId,
|
|
deps.InstanceStore,
|
|
deps.OutcomeStore,
|
|
deps.TemplateRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
deps.TimeProvider);
|
|
Assert.True(completed.Success);
|
|
|
|
// Act
|
|
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
|
|
Assert.NotNull(body);
|
|
var row = Assert.Single(body!.Contracts);
|
|
Assert.Equal(instanceId, row.ContractInstanceId);
|
|
Assert.Equal(ContractListApi.StatusCompleted, row.Status);
|
|
Assert.NotNull(row.CompletedAt);
|
|
AssertPrototypeCompletionRewardSummary(row.CompletionRewardSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetContracts_ShouldReturnSameCompletionRewardSummary_WhenReplayed()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
var deps = ResolveDependencies(factory);
|
|
_ = await client.PostAsync(
|
|
$"/game/players/{PlayerId}/contracts/issue",
|
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
|
_ = ContractCompletionOperations.TryCompleteOnEncounterClear(
|
|
PlayerId,
|
|
EncounterId,
|
|
deps.InstanceStore,
|
|
deps.OutcomeStore,
|
|
deps.TemplateRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
deps.TimeProvider);
|
|
|
|
// Act
|
|
var first = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
|
var second = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
|
|
|
// Assert
|
|
var firstBody = await first.Content.ReadFromJsonAsync<ContractListResponse>();
|
|
var secondBody = await second.Content.ReadFromJsonAsync<ContractListResponse>();
|
|
Assert.NotNull(firstBody);
|
|
Assert.NotNull(secondBody);
|
|
AssertCompletionRewardSummariesEqual(
|
|
Assert.Single(firstBody!.Contracts).CompletionRewardSummary,
|
|
Assert.Single(secondBody!.Contracts).CompletionRewardSummary);
|
|
}
|
|
|
|
private static ContractIssueRequest CreateIssueRequest(string playerId) =>
|
|
new()
|
|
{
|
|
SchemaVersion = ContractIssueRequest.CurrentSchemaVersion,
|
|
PlayerId = playerId,
|
|
TemplateId = TemplateId,
|
|
SeedBucket = SeedBucket,
|
|
};
|
|
|
|
private static void AssertPrototypeCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
|
|
{
|
|
Assert.NotNull(summary);
|
|
var item = Assert.Single(summary!.ItemGrants);
|
|
Assert.Equal("scrap_metal_bulk", item.ItemId);
|
|
Assert.Equal(5, item.Quantity);
|
|
var skill = Assert.Single(summary.SkillXpGrants);
|
|
Assert.Equal("salvage", skill.SkillId);
|
|
Assert.Equal(15, skill.Amount);
|
|
Assert.Empty(summary.ReputationGrants);
|
|
}
|
|
|
|
private static void AssertCompletionRewardSummariesEqual(
|
|
QuestCompletionRewardSummaryJson? first,
|
|
QuestCompletionRewardSummaryJson? second)
|
|
{
|
|
Assert.NotNull(first);
|
|
Assert.NotNull(second);
|
|
AssertPrototypeCompletionRewardSummary(first);
|
|
AssertPrototypeCompletionRewardSummary(second);
|
|
}
|
|
|
|
private static ApiTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
|
{
|
|
using var scope = factory.Services.CreateScope();
|
|
var services = scope.ServiceProvider;
|
|
return new ApiTestDependencies(
|
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
|
services.GetRequiredService<IContractInstanceStore>(),
|
|
services.GetRequiredService<IContractOutcomeStore>(),
|
|
services.GetRequiredService<IItemDefinitionRegistry>(),
|
|
services.GetRequiredService<IPlayerInventoryStore>(),
|
|
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
|
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
|
services.GetRequiredService<ISkillLevelCurve>(),
|
|
services.GetRequiredService<PerkUnlockEngine>(),
|
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
|
services.GetRequiredService<IFactionStandingStore>(),
|
|
services.GetRequiredService<IReputationDeltaStore>(),
|
|
new FakeTimeProvider(CompletedAt));
|
|
}
|
|
|
|
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => utcNow;
|
|
}
|
|
|
|
private sealed record ApiTestDependencies(
|
|
IContractTemplateRegistry TemplateRegistry,
|
|
IContractInstanceStore InstanceStore,
|
|
IContractOutcomeStore OutcomeStore,
|
|
IItemDefinitionRegistry ItemRegistry,
|
|
IPlayerInventoryStore InventoryStore,
|
|
ISkillDefinitionRegistry SkillRegistry,
|
|
IPlayerSkillProgressionStore SkillProgressionStore,
|
|
ISkillLevelCurve LevelCurve,
|
|
PerkUnlockEngine PerkUnlockEngine,
|
|
IPlayerPerkStateStore PerkStore,
|
|
IRewardDeliveryStore DeliveryStore,
|
|
IFactionStandingStore StandingStore,
|
|
IReputationDeltaStore AuditStore,
|
|
TimeProvider TimeProvider);
|
|
}
|