neon-sprawl/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs

405 lines
15 KiB
C#

using NeonSprawl.Server.Game.Contracts;
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;
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);
}