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
parent
b22b68ad1f
commit
2fa2e35ca6
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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({});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ public static class QuestProgressApi
|
|||
var rows = new List<QuestProgressRowJson>(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<string, int>(StringComparer.Ordinal),
|
||||
};
|
||||
return NotStartedRow(questId);
|
||||
}
|
||||
|
||||
return MapQuestProgressRow(snapshot);
|
||||
}
|
||||
|
||||
internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot)
|
||||
{
|
||||
var counters = new Dictionary<string, int>(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<string, int>(StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ app.MapSkillProgressionSnapshotApi();
|
|||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapEncounterProgressApi();
|
||||
app.MapQuestProgressApi();
|
||||
app.MapQuestAcceptApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue