NEO-120: Add quest accept POST API, tests, and Bruno spine.

Wire QuestStateOperations.TryAccept at POST /game/players/{id}/quests/{questId}/accept
with shared quest row projection, integration tests, and Bruno accept flows.
pull/159/head
VinPropane 2026-06-07 12:18:51 -04:00
parent b22b68ad1f
commit 2fa2e35ca6
8 changed files with 428 additions and 11 deletions

View File

@ -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");
});
}

View File

@ -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({});
});
}

View File

@ -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);
});
}

View File

@ -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<QuestAcceptResponse>();
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<QuestProgressListResponse>();
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<QuestAcceptResponse>();
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<QuestAcceptResponse>();
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<QuestProgressListResponse>();
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<QuestAcceptResponse>();
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<QuestAcceptResponse>();
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<QuestAcceptResponse>();
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
Assert.Null(body.Quest);
}
}

View File

@ -0,0 +1,61 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Maps <c>POST /game/players/{{id}}/quests/{{questId}}/accept</c> (NEO-120).</summary>
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,
};
}
}

View File

@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Optional POST body for <c>POST /game/players/{{id}}/quests/{{questId}}/accept</c> (NEO-120).</summary>
public sealed class QuestAcceptRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
}
/// <summary>POST response for quest accept (NEO-120).</summary>
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; }
}

View File

@ -48,7 +48,7 @@ public static class QuestProgressApi
var rows = new List<QuestProgressRowJson>(defs.Count); var rows = new List<QuestProgressRowJson>(defs.Count);
foreach (var def in defs) foreach (var def in defs)
{ {
rows.Add(MapRow(playerId, def.Id, progressStore)); rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore));
} }
return new QuestProgressListResponse return new QuestProgressListResponse
@ -59,35 +59,34 @@ public static class QuestProgressApi
}; };
} }
private static QuestProgressRowJson MapRow( internal static QuestProgressRowJson MapQuestProgressRow(
string playerId, string playerId,
string questId, string questId,
IPlayerQuestStateStore progressStore) IPlayerQuestStateStore progressStore)
{ {
if (!progressStore.TryGetProgress(playerId, questId, out var snapshot)) if (!progressStore.TryGetProgress(playerId, questId, out var snapshot))
{ {
return new QuestProgressRowJson return NotStartedRow(questId);
{
QuestId = questId,
Status = StatusNotStarted,
CurrentStepIndex = 0,
ObjectiveCounters = new Dictionary<string, int>(StringComparer.Ordinal),
};
} }
return MapQuestProgressRow(snapshot);
}
internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot)
{
var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal); var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal);
return snapshot.Status switch return snapshot.Status switch
{ {
QuestProgressStatus.Active => new QuestProgressRowJson QuestProgressStatus.Active => new QuestProgressRowJson
{ {
QuestId = questId, QuestId = snapshot.QuestId,
Status = StatusActive, Status = StatusActive,
CurrentStepIndex = snapshot.CurrentStepIndex, CurrentStepIndex = snapshot.CurrentStepIndex,
ObjectiveCounters = counters, ObjectiveCounters = counters,
}, },
QuestProgressStatus.Completed => new QuestProgressRowJson QuestProgressStatus.Completed => new QuestProgressRowJson
{ {
QuestId = questId, QuestId = snapshot.QuestId,
Status = StatusCompleted, Status = StatusCompleted,
CurrentStepIndex = snapshot.CurrentStepIndex, CurrentStepIndex = snapshot.CurrentStepIndex,
ObjectiveCounters = counters, ObjectiveCounters = counters,
@ -96,4 +95,13 @@ public static class QuestProgressApi
_ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."), _ => 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<string, int>(StringComparer.Ordinal),
};
} }

View File

@ -78,6 +78,7 @@ app.MapSkillProgressionSnapshotApi();
app.MapGigProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi();
app.MapEncounterProgressApi(); app.MapEncounterProgressApi();
app.MapQuestProgressApi(); app.MapQuestProgressApi();
app.MapQuestAcceptApi();
app.MapPerkStateApi(); app.MapPerkStateApi();
if (app.Environment.IsDevelopment() || if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") || app.Environment.IsEnvironment("Testing") ||