NEO-117: Add QuestStateOperations with reason codes and tests.
Server-authoritative accept, step advance, and idempotent complete wrapping IPlayerQuestStateStore with prerequisite enforcement.pull/156/head
parent
44ef5f2484
commit
f053ffbb39
|
|
@ -50,9 +50,15 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Accept fails when prerequisite quest not **completed** (`prerequisite_incomplete`).
|
- [x] Accept fails when prerequisite quest not **completed** (`prerequisite_incomplete`).
|
||||||
- [ ] Complete is idempotent per player+quest (`TryMarkComplete` second call → `Success: true`, same `CompletedAt`).
|
- [x] Complete is idempotent per player+quest (`TryMarkComplete` second call → `Success: true`, same `CompletedAt`).
|
||||||
- [ ] Reason codes documented in `server/README.md`.
|
- [x] Reason codes documented in `server/README.md`.
|
||||||
|
|
||||||
|
## Implementation reconciliation (shipped)
|
||||||
|
|
||||||
|
- **Types:** `QuestStateReasonCodes`, `QuestStateOperationResult`, `QuestStateOperations` (`TryAccept`, `TryAdvanceStep`, `TryMarkComplete`).
|
||||||
|
- **Tests:** `QuestStateOperationsTests` — 15 AAA cases (accept prerequisites, denials, advance bounds, idempotent complete, host DI smoke).
|
||||||
|
- **Docs:** `server/README.md` quest operations + reason-code table.
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||||
|
|
||||||
|
public sealed class QuestStateOperationsTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string UnknownPlayerId = "unknown-player-xyz";
|
||||||
|
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||||
|
private const string RefineQuestId = "prototype_quest_refine_intro";
|
||||||
|
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||||
|
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.ReasonCode);
|
||||||
|
Assert.NotNull(result.Snapshot);
|
||||||
|
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
|
||||||
|
Assert.Equal(0, result.Snapshot.CurrentStepIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
PlayerId,
|
||||||
|
RefineQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, result.ReasonCode);
|
||||||
|
Assert.Null(result.Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldSucceedRefineIntro_WhenGatherIntroCompleted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
PlayerId,
|
||||||
|
RefineQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.ReasonCode);
|
||||||
|
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
PlayerId,
|
||||||
|
"prototype_quest_missing",
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.AlreadyActive, result.ReasonCode);
|
||||||
|
Assert.NotNull(result.Snapshot);
|
||||||
|
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode);
|
||||||
|
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPlayerNotInStore()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAccept(
|
||||||
|
UnknownPlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAdvanceStep_ShouldAdvanceChainQuestAndClearCounters()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
AcceptAndComplete(deps, GatherQuestId, timeProvider);
|
||||||
|
AcceptAndComplete(deps, RefineQuestId, timeProvider);
|
||||||
|
AcceptAndComplete(deps, "prototype_quest_combat_intro", timeProvider);
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainObjectiveId, 5, out _));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAdvanceStep(
|
||||||
|
PlayerId,
|
||||||
|
ChainQuestId,
|
||||||
|
1,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.ReasonCode);
|
||||||
|
Assert.Equal(1, result.Snapshot!.CurrentStepIndex);
|
||||||
|
Assert.Empty(result.Snapshot.ObjectiveCounters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public async Task TryAdvanceStep_ShouldDenyInvalidStepIndex_WhenIndexNotIncreasingOrPastLastStep(int newStepIndex)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
AcceptAndComplete(deps, GatherQuestId, timeProvider);
|
||||||
|
AcceptAndComplete(deps, RefineQuestId, timeProvider);
|
||||||
|
AcceptAndComplete(deps, "prototype_quest_combat_intro", timeProvider);
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAdvanceStep(
|
||||||
|
PlayerId,
|
||||||
|
ChainQuestId,
|
||||||
|
newStepIndex,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.InvalidStepIndex, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAdvanceStep_ShouldDenyNotActive_WhenQuestNotAccepted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAdvanceStep(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
1,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryAdvanceStep_ShouldDenyAlreadyCompleted_WhenQuestCompleted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryAdvanceStep(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
1,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var first = QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider);
|
||||||
|
var second = QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(first.Success);
|
||||||
|
Assert.Null(first.ReasonCode);
|
||||||
|
Assert.NotNull(first.Snapshot!.CompletedAt);
|
||||||
|
Assert.True(second.Success);
|
||||||
|
Assert.Null(second.ReasonCode);
|
||||||
|
Assert.Equal(first.Snapshot.CompletedAt, second.Snapshot!.CompletedAt);
|
||||||
|
Assert.Equal(QuestProgressStatus.Completed, second.Snapshot.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryMarkComplete_ShouldDenyNotActive_WhenQuestNeverAccepted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var timeProvider = TimeProvider.System;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Host_ShouldResolveRegistryAndStore_ForQuestStateOperations()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var questRegistry = scope.ServiceProvider.GetRequiredService<IQuestDefinitionRegistry>();
|
||||||
|
var progressStore = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var accept = QuestStateOperations.TryAccept(PlayerId, GatherQuestId, questRegistry, progressStore);
|
||||||
|
var complete = QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
questRegistry,
|
||||||
|
progressStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(accept.Success);
|
||||||
|
Assert.True(complete.Success);
|
||||||
|
Assert.Equal(QuestProgressStatus.Completed, complete.Snapshot!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AcceptAndComplete(
|
||||||
|
(IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) deps,
|
||||||
|
string questId,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
Assert.True(QuestStateOperations.TryAccept(PlayerId, questId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||||
|
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
questId,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
timeProvider).Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) ResolveDependencies(
|
||||||
|
InMemoryWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
var scope = factory.Services.CreateScope();
|
||||||
|
return (
|
||||||
|
scope.ServiceProvider.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||||
|
scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-internal quest state resolution envelope (NEO-117).
|
||||||
|
/// HTTP DTOs via E7M1-08/09 (NEO-119/NEO-120).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct QuestStateOperationResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReasonCode,
|
||||||
|
QuestStepState? Snapshot);
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative quest accept, step advance, and complete with reason codes (NEO-117).
|
||||||
|
/// Objective wiring: E7M1-07 (NEO-118). HTTP: E7M1-08/09 (NEO-119/NEO-120).
|
||||||
|
/// </summary>
|
||||||
|
public static class QuestStateOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates quest id and prerequisites, then activates at step 0 when not started.
|
||||||
|
/// </summary>
|
||||||
|
public static QuestStateOperationResult TryAccept(
|
||||||
|
string playerId,
|
||||||
|
string questId,
|
||||||
|
IQuestDefinitionRegistry questRegistry,
|
||||||
|
IPlayerQuestStateStore progressStore)
|
||||||
|
{
|
||||||
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
||||||
|
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.UnknownQuest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
|
||||||
|
{
|
||||||
|
return existing.Status switch
|
||||||
|
{
|
||||||
|
QuestProgressStatus.Completed => Deny(QuestStateReasonCodes.AlreadyCompleted, existing),
|
||||||
|
QuestProgressStatus.Active => Deny(QuestStateReasonCodes.AlreadyActive, existing),
|
||||||
|
_ => Deny(QuestStateReasonCodes.UnknownQuest),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var prerequisiteId in definition.PrerequisiteQuestIds)
|
||||||
|
{
|
||||||
|
if (!progressStore.TryGetProgress(playerId, prerequisiteId, out var prerequisite) ||
|
||||||
|
prerequisite.Status != QuestProgressStatus.Completed)
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.PrerequisiteIncomplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
|
||||||
|
{
|
||||||
|
return Success(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deny(QuestStateReasonCodes.UnknownPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances an active quest to <paramref name="newStepIndex"/> when within catalog bounds.
|
||||||
|
/// </summary>
|
||||||
|
public static QuestStateOperationResult TryAdvanceStep(
|
||||||
|
string playerId,
|
||||||
|
string questId,
|
||||||
|
int newStepIndex,
|
||||||
|
IQuestDefinitionRegistry questRegistry,
|
||||||
|
IPlayerQuestStateStore progressStore)
|
||||||
|
{
|
||||||
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
||||||
|
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.UnknownQuest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.NotActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Status == QuestProgressStatus.Completed)
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.AlreadyCompleted, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Status != QuestProgressStatus.Active)
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.NotActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStepIndex <= existing.CurrentStepIndex || newStepIndex >= definition.Steps.Count)
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.InvalidStepIndex, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressStore.TryAdvanceStep(playerId, normalizedQuestId, newStepIndex, out var snapshot))
|
||||||
|
{
|
||||||
|
return Success(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deny(QuestStateReasonCodes.UnknownPlayer, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a quest complete. Idempotent: replays return success with the existing snapshot.
|
||||||
|
/// </summary>
|
||||||
|
public static QuestStateOperationResult TryMarkComplete(
|
||||||
|
string playerId,
|
||||||
|
string questId,
|
||||||
|
IQuestDefinitionRegistry questRegistry,
|
||||||
|
IPlayerQuestStateStore progressStore,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
||||||
|
!questRegistry.TryGetDefinition(normalizedQuestId, out _))
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.UnknownQuest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing) &&
|
||||||
|
existing.Status == QuestProgressStatus.Completed)
|
||||||
|
{
|
||||||
|
return Success(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var active) ||
|
||||||
|
active.Status != QuestProgressStatus.Active)
|
||||||
|
{
|
||||||
|
return Deny(QuestStateReasonCodes.NotActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot))
|
||||||
|
{
|
||||||
|
return Success(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
|
||||||
|
afterAttempt.Status == QuestProgressStatus.Completed)
|
||||||
|
{
|
||||||
|
return Success(afterAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deny(QuestStateReasonCodes.UnknownPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestStateOperationResult Success(QuestStepState snapshot) =>
|
||||||
|
new(Success: true, ReasonCode: null, Snapshot: snapshot);
|
||||||
|
|
||||||
|
private static QuestStateOperationResult Deny(string reasonCode, QuestStepState? snapshot = null) =>
|
||||||
|
new(Success: false, ReasonCode: reasonCode, Snapshot: snapshot);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>Stable deny reason codes for <see cref="QuestStateOperations"/> (NEO-117).</summary>
|
||||||
|
public static class QuestStateReasonCodes
|
||||||
|
{
|
||||||
|
public const string UnknownQuest = "unknown_quest";
|
||||||
|
|
||||||
|
public const string PrerequisiteIncomplete = "prerequisite_incomplete";
|
||||||
|
|
||||||
|
public const string AlreadyCompleted = "already_completed";
|
||||||
|
|
||||||
|
public const string AlreadyActive = "already_active";
|
||||||
|
|
||||||
|
public const string UnknownPlayer = "unknown_player";
|
||||||
|
|
||||||
|
public const string NotActive = "not_active";
|
||||||
|
|
||||||
|
public const string InvalidStepIndex = "invalid_step_index";
|
||||||
|
}
|
||||||
|
|
@ -158,20 +158,44 @@ curl -sS -i "http://localhost:5253/game/world/quest-definitions"
|
||||||
|
|
||||||
## Quest progress store (NEO-116)
|
## Quest progress store (NEO-116)
|
||||||
|
|
||||||
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Mutations are server-authoritative — HTTP read/accept routes land in E7M1-08/09 ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)+ for **`QuestStateOperations`**).
|
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP read/accept routes land in E7M1-08/09 (NEO-119/NEO-120).
|
||||||
|
|
||||||
**Interface methods:**
|
**Store interface methods:**
|
||||||
|
|
||||||
- **`TryGetProgress`** — read one row; false when not started.
|
- **`TryGetProgress`** — read one row; false when not started.
|
||||||
- **`TryActivate`** — first accept (`not_started` → `active`).
|
- **`TryActivate`** — first accept (`not_started` → `active`).
|
||||||
- **`TryAdvanceStep`** — bump step index and clear counters for the new step (denies when completed or index does not increase).
|
- **`TryAdvanceStep`** — bump step index and clear counters for the new step (denies when completed or index does not increase).
|
||||||
- **`TryUpdateObjectiveCounter`** — set one objective counter on the current step (non-negative).
|
- **`TryUpdateObjectiveCounter`** — set one objective counter on the current step (non-negative).
|
||||||
- **`TryMarkComplete`** — idempotent completion flag + **`completedAt`** timestamp.
|
- **`TryMarkComplete`** — first completion returns `true`; store replay returns `false` without changing **`completedAt`**.
|
||||||
|
|
||||||
Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids should be validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** at operation/HTTP layers (NEO-117+).
|
Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids are validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** in **`QuestStateOperations`**.
|
||||||
|
|
||||||
**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP).
|
**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP).
|
||||||
|
|
||||||
|
### Quest state operations (NEO-117)
|
||||||
|
|
||||||
|
**`QuestStateOperations`** (static) wraps the store with catalog validation and structured **`reasonCode`** denials. Returns **`QuestStateOperationResult`** (`success`, optional `reasonCode`, optional progress snapshot).
|
||||||
|
|
||||||
|
| Method | Role |
|
||||||
|
|--------|------|
|
||||||
|
| **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. |
|
||||||
|
| **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. |
|
||||||
|
| **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). |
|
||||||
|
|
||||||
|
**Reason codes:**
|
||||||
|
|
||||||
|
| Code | When |
|
||||||
|
|------|------|
|
||||||
|
| **`unknown_quest`** | Quest id not in catalog (accept, advance, complete). |
|
||||||
|
| **`prerequisite_incomplete`** | Accept when a listed prerequisite is not **completed** (implicit `not_started` fails). |
|
||||||
|
| **`already_completed`** | Accept or advance when row is already **completed**. |
|
||||||
|
| **`already_active`** | Accept when row is already **active**. |
|
||||||
|
| **`unknown_player`** | Player id cannot be written (no dev bucket / missing from Postgres `player_position`). |
|
||||||
|
| **`not_active`** | Advance or complete when quest was never accepted or is not **active**. |
|
||||||
|
| **`invalid_step_index`** | Advance when index does not increase or is past the last step. |
|
||||||
|
|
||||||
|
Objective wiring (gather/craft/encounter hooks) is E7M1-07 ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)). Plan: [NEO-117 implementation plan](../../docs/plans/NEO-117-implementation-plan.md).
|
||||||
|
|
||||||
## Encounter definitions (NEO-103)
|
## Encounter definitions (NEO-103)
|
||||||
|
|
||||||
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue