diff --git a/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru b/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru new file mode 100644 index 0000000..d6f0e4b --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru @@ -0,0 +1,47 @@ +meta { + name: Accept duplicate + type: http + seq: 5 +} + +docs { + NEO-120: second accept on an active quest returns already_active with quest snapshot (no mutation). + Pre-request ensures gather intro is active (idempotent when seq 3 already ran). +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + await axios.post( + `${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`, + { schemaVersion: 1 }, + jsonHeaders, + ); +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_gather_intro/accept + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1 + } +} + +tests { + test("duplicate accept returns already_active with active quest row", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.accepted).to.equal(false); + expect(body.reasonCode).to.equal("already_active"); + expect(body.quest).to.be.an("object"); + expect(body.quest.questId).to.equal("prototype_quest_gather_intro"); + expect(body.quest.status).to.equal("active"); + }); +} diff --git a/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru b/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru new file mode 100644 index 0000000..a35a8b6 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru @@ -0,0 +1,36 @@ +meta { + name: Accept gather intro + type: http + seq: 3 +} + +docs { + NEO-120: POST accept gather intro — not_started to active for dev-local-1. +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_gather_intro/accept + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1 + } +} + +tests { + test("accept returns 200 with accepted true and active quest row", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.accepted).to.equal(true); + expect(body.reasonCode).to.equal(undefined); + expect(body.quest).to.be.an("object"); + expect(body.quest.questId).to.equal("prototype_quest_gather_intro"); + expect(body.quest.status).to.equal("active"); + expect(body.quest.currentStepIndex).to.equal(0); + expect(body.quest.objectiveCounters).to.eql({}); + }); +} diff --git a/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru b/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru new file mode 100644 index 0000000..32cd36e --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru @@ -0,0 +1,32 @@ +meta { + name: Accept refine prerequisite deny + type: http + seq: 4 +} + +docs { + NEO-120: refine intro requires completed gather intro — deny prerequisite_incomplete without mutation. +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_refine_intro/accept + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1 + } +} + +tests { + test("denies accept with prerequisite_incomplete", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.accepted).to.equal(false); + expect(body.reasonCode).to.equal("prerequisite_incomplete"); + expect(body.quest).to.equal(undefined); + }); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs new file mode 100644 index 0000000..29c3a5f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs @@ -0,0 +1,200 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public sealed class QuestAcceptApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; + private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; + + [Fact] + public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/missing-player/quests/{GatherQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/%20/quests/{GatherQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenGatherIntroNotStarted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{GatherQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(QuestAcceptResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.True(body.Accepted); + Assert.Null(body.ReasonCode); + Assert.NotNull(body.Quest); + Assert.Equal(GatherQuestId, body.Quest!.QuestId); + Assert.Equal(QuestProgressApi.StatusActive, body.Quest.Status); + Assert.Equal(0, body.Quest.CurrentStepIndex); + Assert.Empty(body.Quest.ObjectiveCounters); + Assert.Null(body.Quest.CompletedAt); + + var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var progress = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(progress); + var row = Assert.Single(progress!.Quests, static r => r.QuestId == GatherQuestId); + Assert.Equal(QuestProgressApi.StatusActive, row.Status); + } + + [Fact] + public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyOmitted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{GatherQuestId}/accept", + content: null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Accepted); + Assert.Equal(GatherQuestId, body.Quest!.QuestId); + } + + [Fact] + public async Task PostQuestAccept_ShouldReturnBadRequest_WhenSchemaVersionInvalid() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + using var content = new StringContent( + JsonSerializer.Serialize(new { schemaVersion = 99 }), + Encoding.UTF8, + "application/json"); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{GatherQuestId}/accept", + content); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostQuestAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{RefineQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, body.ReasonCode); + Assert.Null(body.Quest); + + var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + var progress = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(progress); + var row = Assert.Single(progress!.Quests, static r => r.QuestId == RefineQuestId); + Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status); + } + + [Fact] + public async Task PostQuestAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var first = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{GatherQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + var firstBody = await first.Content.ReadFromJsonAsync(); + Assert.NotNull(firstBody); + Assert.True(firstBody!.Accepted); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{GatherQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(QuestStateReasonCodes.AlreadyActive, body.ReasonCode); + Assert.NotNull(body.Quest); + Assert.Equal(QuestProgressApi.StatusActive, body.Quest!.Status); + Assert.Equal(firstBody.Quest!.CurrentStepIndex, body.Quest.CurrentStepIndex); + } + + [Fact] + public async Task PostQuestAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/prototype_quest_missing/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode); + Assert.Null(body.Quest); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs new file mode 100644 index 0000000..99de8bb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs @@ -0,0 +1,61 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Quests; + +/// Maps POST /game/players/{{id}}/quests/{{questId}}/accept (NEO-120). +public static class QuestAcceptApi +{ + public static WebApplication MapQuestAcceptApi(this WebApplication app) + { + app.MapPost( + "/game/players/{id}/quests/{questId}/accept", + (string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions, + IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, + IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + TimeProvider timeProvider) => + { + if (body is not null && + body.SchemaVersion != 0 && + body.SchemaVersion != QuestAcceptRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var result = QuestStateOperations.TryAccept( + trimmedId, + questId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry, + timeProvider); + + return Results.Json(MapResponse(result)); + }); + + return app; + } + + internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result) + { + QuestProgressRowJson? quest = null; + if (result.Snapshot is { } snapshot) + { + quest = QuestProgressApi.MapQuestProgressRow(snapshot); + } + + return new QuestAcceptResponse + { + Accepted = result.Success, + ReasonCode = result.ReasonCode, + Quest = quest, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestAcceptDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestAcceptDtos.cs new file mode 100644 index 0000000..d4702df --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestAcceptDtos.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Quests; + +/// Optional POST body for POST /game/players/{{id}}/quests/{{questId}}/accept (NEO-120). +public sealed class QuestAcceptRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } +} + +/// POST response for quest accept (NEO-120). +public sealed class QuestAcceptResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("accepted")] + public bool Accepted { get; init; } + + [JsonPropertyName("reasonCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasonCode { get; init; } + + [JsonPropertyName("quest")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public QuestProgressRowJson? Quest { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index 6403e75..2c9bed6 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -48,7 +48,7 @@ public static class QuestProgressApi var rows = new List(defs.Count); foreach (var def in defs) { - rows.Add(MapRow(playerId, def.Id, progressStore)); + rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore)); } return new QuestProgressListResponse @@ -59,35 +59,34 @@ public static class QuestProgressApi }; } - private static QuestProgressRowJson MapRow( + internal static QuestProgressRowJson MapQuestProgressRow( string playerId, string questId, IPlayerQuestStateStore progressStore) { if (!progressStore.TryGetProgress(playerId, questId, out var snapshot)) { - return new QuestProgressRowJson - { - QuestId = questId, - Status = StatusNotStarted, - CurrentStepIndex = 0, - ObjectiveCounters = new Dictionary(StringComparer.Ordinal), - }; + return NotStartedRow(questId); } + return MapQuestProgressRow(snapshot); + } + + internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot) + { var counters = new Dictionary(snapshot.ObjectiveCounters, StringComparer.Ordinal); return snapshot.Status switch { QuestProgressStatus.Active => new QuestProgressRowJson { - QuestId = questId, + QuestId = snapshot.QuestId, Status = StatusActive, CurrentStepIndex = snapshot.CurrentStepIndex, ObjectiveCounters = counters, }, QuestProgressStatus.Completed => new QuestProgressRowJson { - QuestId = questId, + QuestId = snapshot.QuestId, Status = StatusCompleted, CurrentStepIndex = snapshot.CurrentStepIndex, ObjectiveCounters = counters, @@ -96,4 +95,13 @@ public static class QuestProgressApi _ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."), }; } + + private static QuestProgressRowJson NotStartedRow(string questId) => + new() + { + QuestId = questId, + Status = StatusNotStarted, + CurrentStepIndex = 0, + ObjectiveCounters = new Dictionary(StringComparer.Ordinal), + }; } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 9c65166..9db9831 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -78,6 +78,7 @@ app.MapSkillProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi(); app.MapEncounterProgressApi(); app.MapQuestProgressApi(); +app.MapQuestAcceptApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") ||