NEO-117: Fix Bugbot deny-code misclassification on quest accept.

Add CanWritePlayer to IPlayerQuestStateStore and check before
prerequisite gate; map TryActivate/TryAdvanceStep store failures
from progress snapshots instead of always unknown_player.
pull/156/head
VinPropane 2026-06-06 19:36:41 -04:00
parent 025c3250d2
commit 540257d12d
7 changed files with 139 additions and 3 deletions

View File

@ -30,6 +30,12 @@ NEO-117 adds **`QuestStateOperations`** — static accept, step advance, and com
(none)
## Bugbot follow-up (2026-06-06)
1. ~~**TryActivate failure misclassified** — Map store mutation failures from returned snapshots / re-read progress instead of always `unknown_player`.~~ **Done.** `DenyActivateFailure` / `DenyAdvanceFailure` helpers; store replay on `TryMarkComplete` returns idempotent success.
2. ~~**Unknown player wrong deny code** — Check `CanWritePlayer` before prerequisite gate so unknown players get `unknown_player`, not `prerequisite_incomplete`.~~ **Done.** Added `IPlayerQuestStateStore.CanWritePlayer` (in-memory + Postgres); `TryAccept` calls it before prerequisites.
## Suggestions
1. ~~**Test `ResolveDependencies` pattern** — `QuestStateOperationsTests.ResolveDependencies` creates an `IServiceScope` that is never disposed. **`EncounterCompletionOperationsTests`** resolves from `factory.Services` root (singleton stores). Align to avoid undisposed scopes and match the established encounter test pattern.~~ **Done.** `ResolveDependencies` and host smoke test resolve from `factory.Services`.

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
@ -154,6 +155,25 @@ public sealed class QuestStateOperationsTests
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
}
[Fact]
public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPrerequisiteQuestNotWritable()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
UnknownPlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode);
}
[Fact]
public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPlayerNotInStore()
{
@ -392,6 +412,30 @@ public sealed class QuestStateOperationsTests
Assert.Equal(QuestProgressStatus.Completed, complete.Snapshot!.Status);
}
[Fact]
public void CanWritePlayer_ShouldReturnFalse_ForUnknownPlayer()
{
// Arrange
var store = new InMemoryPlayerQuestStateStore(
Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }));
// Act
var canWrite = store.CanWritePlayer(UnknownPlayerId);
// Assert
Assert.False(canWrite);
}
[Fact]
public void CanWritePlayer_ShouldReturnTrue_ForDevPlayer()
{
// Arrange
var store = new InMemoryPlayerQuestStateStore(
Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }));
// Act
var canWrite = store.CanWritePlayer(PlayerId);
// Assert
Assert.True(canWrite);
}
private static void AcceptAndComplete(
(IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) deps,
string questId,

View File

@ -6,6 +6,9 @@ namespace NeonSprawl.Server.Game.Quests;
/// </summary>
public interface IPlayerQuestStateStore
{
/// <summary>True when mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
bool CanWritePlayer(string playerId);
/// <summary>Missing row ⇒ <c>false</c> (callers treat as <c>not_started</c>).</summary>
bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot);

View File

@ -66,6 +66,13 @@ public sealed class InMemoryPlayerQuestStateStore(IOptions<GamePositionOptions>
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
}
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = QuestProgressIds.NormalizePlayerId(playerId);
return player.Length > 0 && knownPlayers.Contains(player);
}
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{

View File

@ -10,6 +10,20 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = QuestProgressIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
return PlayerExists(conn, player);
}
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{

View File

@ -21,6 +21,11 @@ public static class QuestStateOperations
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (!progressStore.CanWritePlayer(playerId))
{
return Deny(QuestStateReasonCodes.UnknownPlayer);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
{
if (existing.Status == QuestProgressStatus.Completed)
@ -45,7 +50,7 @@ public static class QuestStateOperations
return Success(snapshot);
}
return Deny(QuestStateReasonCodes.UnknownPlayer);
return DenyActivateFailure(progressStore, playerId, normalizedQuestId, snapshot);
}
/// <summary>
@ -89,7 +94,7 @@ public static class QuestStateOperations
return Success(snapshot);
}
return Deny(QuestStateReasonCodes.UnknownPlayer, existing);
return DenyAdvanceFailure(progressStore, playerId, normalizedQuestId, snapshot, existing);
}
/// <summary>
@ -128,15 +133,71 @@ public static class QuestStateOperations
return Success(snapshot);
}
if (snapshot?.Status == QuestProgressStatus.Completed)
{
return Success(snapshot);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
afterAttempt.Status == QuestProgressStatus.Completed)
{
return Success(afterAttempt);
}
if (!progressStore.CanWritePlayer(playerId))
{
return Deny(QuestStateReasonCodes.UnknownPlayer);
}
return DenyFromProgressSnapshot(snapshot, QuestStateReasonCodes.NotActive)
?? Deny(QuestStateReasonCodes.UnknownPlayer, progress);
}
private static QuestStateOperationResult DenyActivateFailure(
IPlayerQuestStateStore progressStore,
string playerId,
string questId,
QuestStepState attemptSnapshot) =>
DenyFromProgressSnapshot(attemptSnapshot, QuestStateReasonCodes.AlreadyActive)
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.AlreadyActive)
: null)
?? Deny(QuestStateReasonCodes.UnknownPlayer);
private static QuestStateOperationResult DenyAdvanceFailure(
IPlayerQuestStateStore progressStore,
string playerId,
string questId,
QuestStepState attemptSnapshot,
QuestStepState priorRead) =>
DenyFromProgressSnapshot(attemptSnapshot, QuestStateReasonCodes.InvalidStepIndex)
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.InvalidStepIndex)
: null)
?? Deny(QuestStateReasonCodes.UnknownPlayer, priorRead);
private static QuestStateOperationResult? DenyFromProgressSnapshot(
QuestStepState? snapshot,
string activeOrIntermediateReasonCode)
{
if (snapshot is null)
{
return null;
}
if (snapshot.Status == QuestProgressStatus.Completed)
{
return Deny(QuestStateReasonCodes.AlreadyCompleted, snapshot);
}
if (snapshot.Status == QuestProgressStatus.Active)
{
return Deny(activeOrIntermediateReasonCode, snapshot);
}
return null;
}
private static QuestStateOperationResult Success(QuestStepState snapshot) =>
new(Success: true, ReasonCode: null, Snapshot: snapshot);

View File

@ -162,6 +162,7 @@ Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by *
**Store interface methods:**
- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`).
- **`TryGetProgress`** — read one row; false when not started.
- **`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).