NEO-137: Wire FactionGateOperations into quest accept with tests and docs.
Evaluate FactionGateRule rows before TryActivate; deny with faction_gate_blocked when standing is below minStanding. Integration and Bruno deny smoke included.pull/176/head
parent
1c3d075d6b
commit
0241cc31e3
|
|
@ -0,0 +1,35 @@
|
|||
meta {
|
||||
name: Accept grid contract faction gate deny
|
||||
type: http
|
||||
seq: 10
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15.
|
||||
Prerequisite setup: mark prototype_quest_operator_chain completed (store seed or full chain playthrough).
|
||||
Fresh dev player with chain completed but standing 0 denies faction_gate_blocked before activation.
|
||||
Success Bruno deferred to NEO-138 when operator-chain rep grants land.
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_grid_contract/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies accept with faction_gate_blocked when standing below minStanding", 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("faction_gate_blocked");
|
||||
expect(body.quest).to.equal(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M3 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Pre-production |
|
||||
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-05** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
|
||||
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-06** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
|
||||
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
|
||||
|
||||
## Purpose
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -56,9 +56,18 @@ Other decisions settled by Linear AC, backlog, and repo precedent (no additional
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
|
||||
- [ ] Accept succeeds when all rules satisfied.
|
||||
- [ ] `dotnet test` covers gate deny, threshold success, and no-gate quests.
|
||||
- [x] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
|
||||
- [x] Accept succeeds when all rules satisfied.
|
||||
- [x] `dotnet test` covers gate deny, threshold success, and no-gate quests.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Operations:** `FactionGateOperations.TryEvaluate` — AND gate eval via standing store reads.
|
||||
- **Types:** `FactionGateEvaluateOutcome`, `FactionGateEvaluateReasonCodes`.
|
||||
- **Wire:** `QuestStateOperations.TryAccept` + `QuestAcceptApi` inject `IFactionStandingStore`; `QuestStateReasonCodes.FactionGateBlocked`.
|
||||
- **Tests:** `FactionGateOperationsTests` (5 cases), grid contract integration in `QuestStateOperationsTests` + `QuestAcceptApiTests` (790 tests green).
|
||||
- **Bruno:** deny-only `Accept grid contract faction gate deny.bru`; success deferred to NEO-138.
|
||||
- **Docs:** `server/README.md` FactionGateOperations section; E7.M3 module + alignment register updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
public sealed class FactionGateOperationsTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||
private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding;
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldPass_WhenRulesEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, [], standingStore);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
Assert.Null(outcome.FailingFactionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldPass_WhenStandingAtMin()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
|
||||
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldDeny_WhenStandingBelowMin()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding - 1).Success);
|
||||
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
|
||||
Assert.Equal(GridFactionId, outcome.FailingFactionId);
|
||||
Assert.Equal(GridMinStanding, outcome.RequiredMinStanding);
|
||||
Assert.Equal(GridMinStanding - 1, outcome.CurrentStanding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
const string unknownFactionId = "prototype_faction_missing";
|
||||
var rules = new[] { new FactionGateRuleRow(unknownFactionId, 0) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode);
|
||||
Assert.Equal(unknownFactionId, outcome.FailingFactionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveStandingStoreForGateEval_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
private static InMemoryFactionStandingStore CreateStandingStore()
|
||||
{
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
return new InMemoryFactionStandingStore(options, registry);
|
||||
}
|
||||
|
||||
private static FactionDefinitionRegistry CreatePrototypeRegistry()
|
||||
{
|
||||
var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||
var rustFactionId = "prototype_faction_rust_collective";
|
||||
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId],
|
||||
[rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId],
|
||||
};
|
||||
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||
return new FactionDefinitionRegistry(catalog);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
|
|
@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests
|
|||
private const string PlayerId = "dev-local-1";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
|
|
@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests
|
|||
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
|
||||
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
|
||||
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
|
||||
var standingStore = services.GetRequiredService<IFactionStandingStore>();
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
|
|
@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests
|
|||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
standingStore,
|
||||
timeProvider).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
|
|
@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests
|
|||
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||
Assert.True(progressStore.TryActivate(PlayerId, ChainQuestId, out _));
|
||||
Assert.True(progressStore.TryMarkComplete(PlayerId, ChainQuestId, DateTimeOffset.UtcNow, out _));
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GridContractQuestId}/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.FactionGateBlocked, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
|
@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
|
||||
|
|
@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests
|
|||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests
|
|||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
|
@ -400,6 +401,7 @@ public sealed class QuestProgressApiTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
|
||||
|
|
@ -493,6 +495,7 @@ public sealed class QuestProgressApiTests
|
|||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -515,5 +518,6 @@ public sealed class QuestProgressApiTests
|
|||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests
|
|||
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
|
||||
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
CompleteChainPrerequisite(deps);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GridContractQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode);
|
||||
Assert.Null(result.Snapshot);
|
||||
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, GridContractQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
CompleteChainPrerequisite(deps);
|
||||
SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GridContractQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenStandingOneBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
CompleteChainPrerequisite(deps);
|
||||
SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding - 1);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GridContractQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
|
||||
|
|
@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests
|
|||
Assert.True(canWrite);
|
||||
}
|
||||
|
||||
private static void CompleteChainPrerequisite(QuestTestDependencies deps)
|
||||
{
|
||||
Assert.True(deps.ProgressStore.TryActivate(PlayerId, ChainQuestId, out _));
|
||||
Assert.True(deps.ProgressStore.TryMarkComplete(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
DateTimeOffset.UtcNow,
|
||||
out _));
|
||||
}
|
||||
|
||||
private static void SeedGridStanding(QuestTestDependencies deps, int standing)
|
||||
{
|
||||
if (standing == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var auditStore = deps.ReputationDeltaStore;
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
standing,
|
||||
$"seed-{standing}",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
ChainQuestId,
|
||||
deps.StandingStore,
|
||||
auditStore,
|
||||
deps.TimeProvider);
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(standing, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
{
|
||||
var total = 0;
|
||||
|
|
@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
|
||||
|
|
@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests
|
|||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
services.GetRequiredService<IReputationDeltaStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests
|
|||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
IReputationDeltaStore ReputationDeltaStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Result of <see cref="FactionGateOperations.TryEvaluate"/> (NEO-137).</summary>
|
||||
public readonly record struct FactionGateEvaluateOutcome(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
string? FailingFactionId,
|
||||
int? RequiredMinStanding,
|
||||
int? CurrentStanding);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Stable deny reason codes for <see cref="FactionGateOperations.TryEvaluate"/> (NEO-137).</summary>
|
||||
public static class FactionGateEvaluateReasonCodes
|
||||
{
|
||||
public const string GateBlocked = "gate_blocked";
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using NeonSprawl.Server.Game.Quests;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates <see cref="FactionGateRuleRow"/> minimum-standing gates (NEO-137).
|
||||
/// Quest accept wiring: <see cref="QuestStateOperations.TryAccept"/>.
|
||||
/// NEO-141 telemetry hook site: deny return path below.
|
||||
/// </summary>
|
||||
public static class FactionGateOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// All rules must pass (AND). Empty rules succeed immediately.
|
||||
/// Compares player standing vs <see cref="FactionGateRuleRow.MinStanding"/> with <c>standing >= minStanding</c>.
|
||||
/// </summary>
|
||||
public static FactionGateEvaluateOutcome TryEvaluate(
|
||||
string playerId,
|
||||
IReadOnlyList<FactionGateRuleRow> rules,
|
||||
IFactionStandingStore standingStore)
|
||||
{
|
||||
var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return Success();
|
||||
}
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var factionId = FactionStandingIds.NormalizeFactionId(rule.FactionId);
|
||||
var read = standingStore.TryGetStanding(normalizedPlayerId, factionId);
|
||||
if (!read.Success)
|
||||
{
|
||||
return Deny(read.ReasonCode, factionId, rule.MinStanding, null);
|
||||
}
|
||||
|
||||
if (read.Standing < rule.MinStanding)
|
||||
{
|
||||
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `faction_gate_blocked` ---
|
||||
// TODO(E9.M1): catalog emit — playerId, factionId, minStanding, currentStanding, gate context.
|
||||
// No ingest or ILogger here (comments-only).
|
||||
return Deny(
|
||||
FactionGateEvaluateReasonCodes.GateBlocked,
|
||||
factionId,
|
||||
rule.MinStanding,
|
||||
read.Standing);
|
||||
}
|
||||
}
|
||||
|
||||
return Success();
|
||||
}
|
||||
|
||||
private static FactionGateEvaluateOutcome Success() =>
|
||||
new(true, null, null, null, null);
|
||||
|
||||
private static FactionGateEvaluateOutcome Deny(
|
||||
string? reasonCode,
|
||||
string factionId,
|
||||
int minStanding,
|
||||
int? currentStanding) =>
|
||||
new(false, reasonCode, factionId, minStanding, currentStanding);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -18,7 +19,8 @@ public static class QuestAcceptApi
|
|||
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
||||
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
|
||||
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
|
||||
IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is not null &&
|
||||
body.SchemaVersion != 0 &&
|
||||
|
|
@ -46,6 +48,7 @@ public static class QuestAcceptApi
|
|||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
standingStore,
|
||||
timeProvider);
|
||||
|
||||
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Rewards;
|
||||
|
|
@ -29,6 +30,7 @@ public static class QuestStateOperations
|
|||
PerkUnlockEngine perkUnlockEngine,
|
||||
IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
IFactionStandingStore standingStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
||||
|
|
@ -61,6 +63,15 @@ public static class QuestStateOperations
|
|||
}
|
||||
}
|
||||
|
||||
var gate = FactionGateOperations.TryEvaluate(
|
||||
playerId,
|
||||
definition.FactionGateRules,
|
||||
standingStore);
|
||||
if (!gate.Success)
|
||||
{
|
||||
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
|
||||
}
|
||||
|
||||
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
|
||||
{
|
||||
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---
|
||||
|
|
|
|||
|
|
@ -16,4 +16,6 @@ public static class QuestStateReasonCodes
|
|||
public const string NotActive = "not_active";
|
||||
|
||||
public const string InvalidStepIndex = "invalid_step_index";
|
||||
|
||||
public const string FactionGateBlocked = "faction_gate_blocked";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,18 @@ Game code applies standing changes through **`ReputationOperations.TryApplyDelta
|
|||
|
||||
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md).
|
||||
|
||||
## FactionGateOperations (NEO-137)
|
||||
|
||||
Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperations.TryEvaluate`** before activation. Wired from **`QuestStateOperations.TryAccept`** after prerequisite checks and before **`TryActivate`**.
|
||||
|
||||
**Semantics:** all rules must pass (AND). Empty rule list succeeds immediately. Each rule compares player standing from **`IFactionStandingStore.TryGetStanding`** against **`minStanding`** with **`standing >= minStanding`**. Unknown faction ids fail closed via the standing store boundary (startup catalog cross-ref prevents bad content at load).
|
||||
|
||||
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
|
||||
|
||||
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. Rep grants that reach +15 standing land in NEO-138; success Bruno smoke deferred until then.
|
||||
|
||||
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
|
||||
|
||||
## Resource-node catalog (`content/resource-nodes`, NEO-58)
|
||||
|
||||
On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||
|
|
@ -281,14 +293,14 @@ Sample default response (no quests accepted):
|
|||
|
||||
### Quest accept POST (NEO-120)
|
||||
|
||||
**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted).
|
||||
**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted). Faction-gated quests (NEO-137) deny with **`faction_gate_blocked`** when standing is below any **`FactionGateRule.minStanding`** after prerequisites pass.
|
||||
|
||||
Response (`schemaVersion` **1**): **`accepted`** (`true`/`false`), optional **`reasonCode`** on deny, optional **`quest`** row (same shape as GET list entries). Structured denies return **HTTP 200** with **`accepted: false`** (inventory/craft precedent). Accept-time **`inventory_has_item`** wiring runs inside **`TryAccept`** (NEO-118).
|
||||
|
||||
| Outcome | `accepted` | `reasonCode` | `quest` row |
|
||||
|---------|------------|--------------|-------------|
|
||||
| Success | `true` | omitted | active snapshot |
|
||||
| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete` | omitted |
|
||||
| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete`, `faction_gate_blocked` | omitted |
|
||||
| Deny (existing row) | `false` | e.g. `already_active`, `already_completed` | current snapshot |
|
||||
|
||||
Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).
|
||||
|
|
|
|||
Loading…
Reference in New Issue