Merge pull request #176 from ViPro-Technologies/NEO-137-e7m3-faction-gate-operations-tryaccept

NEO-137: FactionGateOperations + wire into TryAccept
pull/177/head
VinPropane 2026-06-15 21:32:48 -04:00 committed by GitHub
commit 0af21e002c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 971 additions and 6 deletions

View File

@ -93,6 +93,7 @@ jobs:
ConnectionStrings__NeonSprawl: >- ConnectionStrings__NeonSprawl: >-
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
Game__EnableCombatTargetFixtureApi: "true" Game__EnableCombatTargetFixtureApi: "true"
Game__EnableQuestFixtureApi: "true"
run: | run: |
set -euo pipefail set -euo pipefail
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \

View File

@ -0,0 +1,56 @@
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.
Pre-request marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing stays 0).
Success Bruno deferred to NEO-138 when operator-chain rep grants land.
}
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" } };
const chainQuestId = "prototype_quest_operator_chain";
const fixture = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{
schemaVersion: 1,
completedQuestIds: [chainQuestId],
},
{ ...jsonHeaders, validateStatus: () => true },
);
if (fixture.status !== 200 || fixture.data?.applied !== true) {
throw new Error(
`quest-fixture failed: HTTP ${fixture.status} ${JSON.stringify(fixture.data)}`,
);
}
}
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);
});
}

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 | | **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production | | **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) | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose ## Purpose

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,210 @@
# NEO-137 — E7M3-05: FactionGateOperations + wire into TryAccept
**Linear:** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)
**Branch:** `NEO-137-e7m3-faction-gate-operations-tryaccept`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-05**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-136](NEO-136-implementation-plan.md) — static ops + structured outcome + reason codes; [NEO-117](NEO-117-implementation-plan.md) — `QuestStateOperations.TryAccept` deny path + `QuestStateReasonCodes`
**Precursors:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `FactionGateRuleRow` on `QuestDefRow`, catalog cross-ref gates; [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore.TryGetStanding` (**landed on `main`**)
**Blocks:** [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites — comment-only consolidation)
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD gate feedback (blocked by E7M3-07/E7M3-08)
## Goal
Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation. Standing below **`minStanding`** denies with **`faction_gate_blocked`**; all rules satisfied (AND) allows accept to proceed to **`TryActivate`**.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|----------------------|--------|
| Bruno smoke scope | Backlog lists deny + success Bruno on **`prototype_quest_grid_contract`**, but rep grants (+15 standing) are not wired until **NEO-138** and there is no HTTP standing seed until **NEO-139**. | **Deny-only Bruno** in NEO-137; **success at threshold** via integration tests seeding standing through **`ReputationOperations`** / store; defer **success Bruno** to **NEO-138** when operator-chain completion grants +15 organically. | **Adopted** — deny-only Bruno now |
Other decisions settled by Linear AC, backlog, and repo precedent (no additional questions):
| Topic | Decision | Evidence |
|-------|----------|----------|
| Gate semantics | **AND** — all rules must pass; empty rule list ⇒ pass | Backlog E7M3-05 |
| Comparison | **`standing >= minStanding`** succeeds; strictly below denies | Linear AC “below minStanding” / “succeeds when all rules satisfied”; grid contract **`minStanding` 15** |
| Unknown faction at runtime | **Fail-closed via standing store** if ever reached; startup catalog cross-ref (NEO-134) prevents bad content | Backlog “unknown faction … fails closed at startup (E7M3-02) not runtime” + defense-in-depth |
| Ops shape | **Static `FactionGateOperations.TryEvaluate`** + **`FactionGateEvaluateOutcome`** | `ReputationOperations`, `EncounterCompletionOperations` precedent |
| Wire site | After prerequisite check, **before `TryActivate`** | Backlog E7M3-05 |
| Reason code | **`faction_gate_blocked`** on **`QuestStateReasonCodes`** | Backlog + epic telemetry vocabulary |
| Telemetry | **Comment-only `faction_gate_blocked` hook** in deny path (NEO-141 README consolidation later) | NEO-136 added `reputation_delta` hook in same slice; backlog E7M3-05 lists hook site |
| HTTP / Godot / travel gates | **Out of scope** | Backlog E7M3-05; E4.M1 deferred |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`FactionGateOperations.TryEvaluate`** — AND all rules; read standing via **`IFactionStandingStore.TryGetStanding`** per rule.
- **`FactionGateEvaluateOutcome`** (+ optional **`FactionGateEvaluateReasonCodes`** if needed beyond quest-level code).
- Wire into **`QuestStateOperations.TryAccept`** after prerequisites, before activation.
- **`QuestStateReasonCodes.FactionGateBlocked`** = **`faction_gate_blocked`**.
- Pass **`IFactionStandingStore`** through **`TryAccept`**, **`QuestAcceptApi`**, and test helpers.
- Integration tests: grid contract denied at standing **14** (prereq complete); succeeds at **15**; quests without gates unaffected.
- **Deny-only** Bruno: accept **`prototype_quest_grid_contract`** on fresh dev player ⇒ **`faction_gate_blocked`** (prerequisite completed via prior Bruno steps or documented seq).
- Comment-only telemetry hook on gate deny path.
- `server/README.md` **`FactionGateOperations`** section.
**Out of scope:**
- **`reputationGrants`** delivery (NEO-138); success Bruno when standing comes from operator-chain completion (NEO-138).
- HTTP GET standing (NEO-139), Godot HUD (NEO-142), zone/travel gates (E4.M1).
- E9.M1 telemetry ingest (NEO-141 documents hooks only).
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — surfaces **`faction_gate_blocked`** on accept feedback (requires NEO-139/NEO-140 HTTP).
## Acceptance criteria checklist
- [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` with `POST …/__dev/quest-fixture` pre-request; success deferred to NEO-138.
- **Dev fixture:** `QuestFixtureApi` marks quests completed without reward delivery (Bruno CI prerequisite).
- **Docs:** `server/README.md` FactionGateOperations section; E7.M3 module + alignment register updated.
## Technical approach
### 1. Types (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionGateEvaluateOutcome` | `Success`, optional `ReasonCode`, optional failing rule context (`FactionId`, `MinStanding`, `CurrentStanding`) for telemetry/HUD |
| `FactionGateEvaluateReasonCodes` | `gate_blocked` (internal ops code if distinct from quest code); passthrough **`unknown_faction`** from standing store |
Quest-level deny uses **`QuestStateReasonCodes.FactionGateBlocked`** (`faction_gate_blocked`) — same string epic telemetry expects.
### 2. `FactionGateOperations.TryEvaluate`
```csharp
public static FactionGateEvaluateOutcome TryEvaluate(
string playerId,
IReadOnlyList<FactionGateRuleRow> rules,
IFactionStandingStore standingStore)
```
**Flow:**
1. Normalize **`playerId`** via **`FactionStandingIds`**.
2. **Empty rules** ⇒ success immediately (vacuous AND).
3. For each rule in order:
- Normalize **`rule.FactionId`**.
- **`standingStore.TryGetStanding(playerId, factionId)`** — on store deny, return outcome with store **`ReasonCode`** (defense-in-depth **`unknown_faction`**).
- If **`standing < rule.MinStanding`**, return deny with failing rule context and ops reason **`gate_blocked`**.
4. All rules pass ⇒ success.
XML remarks: called from **`QuestStateOperations.TryAccept`** only in prototype; future E4.M1 travel gates may reuse. NEO-141 telemetry hook site on deny return.
### 3. Wire `QuestStateOperations.TryAccept`
After prerequisite loop, before **`TryActivate`**:
```csharp
var gate = FactionGateOperations.TryEvaluate(playerId, definition.FactionGateRules, standingStore);
if (!gate.Success)
{
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
}
```
Add **`IFactionStandingStore standingStore`** parameter to **`TryAccept`** (and all internal/test call sites). **`QuestAcceptApi`** resolves store from DI.
**DenyAccept** already threads **`quest_accept_denied`** telemetry hook (NEO-121); gate deny inherits that path with **`reasonCode: faction_gate_blocked`**.
### 4. Prototype test scenarios
| Scenario | Setup | Expected |
|----------|-------|----------|
| Below threshold | Complete **`prototype_quest_operator_chain`** prerequisite only; standing **0** (no rep wiring) | **`faction_gate_blocked`**, no activation |
| At threshold | Same prereq + seed **+15** Grid Operators via **`ReputationOperations.TryApplyDelta`** | Accept succeeds, quest active |
| No gates | Accept **`prototype_quest_gather_intro`** | Unchanged behavior |
| Standing 14 | Seed **+14** via ops | **`faction_gate_blocked`** |
Constants: **`PrototypeE7M3QuestFactionRules.GridContractQuestId`**, **`GridContractGateFactionId`**, **`GridContractMinStanding`** (15), **`ChainQuestId`** prerequisite.
### 5. Bruno (deny-only)
New request under **`bruno/neon-sprawl-server/quest-progress/`** (or subfolder):
- Complete operator-chain prerequisite path is heavy for Bruno; **minimal deny smoke**: accept grid contract on default dev player with **operator chain completed** but **standing 0** — requires Bruno sequence or docs referencing manual prereq completion.
- **Practical seq:** reuse existing accept/complete Bruno steps for operator chain where possible, then accept grid contract ⇒ **`faction_gate_blocked`**. If operator-chain complete Bruno is too heavy, document seq number after gather/chain smokes and add a focused deny request assuming fresh DB with chain already completed via prior collection run.
Defer **success accept Bruno** to **NEO-138** (operator-chain completion applies +15 rep).
### 6. Tests
**`FactionGateOperationsTests`** — unit AAA on in-memory standing store:
| Test | Covers |
|------|--------|
| `TryEvaluate_ShouldPass_WhenRulesEmpty` | Vacuous AND |
| `TryEvaluate_ShouldPass_WhenStandingAtMin` | standing **15**, min **15** |
| `TryEvaluate_ShouldDeny_WhenStandingBelowMin` | standing **14**, min **15**; failing context |
| `TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies` | Invalid faction id (if injectable) |
**Extend `QuestStateOperationsTests`** (or add **`QuestFactionGateIntegrationTests`**):
| Test | Covers |
|------|--------|
| `TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold` | Prereq complete, standing 0 |
| `TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold` | Seed +15, accept succeeds |
| `TryAccept_ShouldAcceptGatherIntro_WhenNoGateRules` | Regression |
**Extend `QuestAcceptApiTests`**: HTTP POST grid contract deny ⇒ **`faction_gate_blocked`** body.
**Host DI smoke:** resolve **`IFactionStandingStore`** + one gate eval through factory (can live in **`FactionGateOperationsTests`**).
Manual QA: **none** (server engine; Bruno deny smoke replaces player-visible checklist until NEO-142).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateOutcome.cs` | Structured gate eval result with optional failing rule context |
| `server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateReasonCodes.cs` | Ops-level codes (`gate_blocked`; store passthrough) |
| `server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs` | `TryEvaluate` AND logic over standing reads |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs` | AAA unit + host DI smoke |
| `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` | Deny-only Bruno smoke for **`faction_gate_blocked`** |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs` | Add **`FactionGateBlocked`** constant |
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Inject standing store; call gate eval before activate; telemetry hook comment on gate deny |
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | Resolve **`IFactionStandingStore`**; pass to **`TryAccept`** |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Gate integration tests; update **`TryAccept`** helper signature |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | HTTP deny test for grid contract |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryAccept`** helper if shared |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Update **`TryAccept`** call sites |
| `server/README.md` | Document **`FactionGateOperations`**, accept gate order, **`faction_gate_blocked`**, Bruno note |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status: E7M3-05 in progress / landed |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-137 plan |
## Tests
| File | Action | Coverage |
|------|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs` | **Add** | Empty rules, at/below threshold, store deny passthrough, host DI |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | **Change** | Grid contract deny/success; no-gate regression |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | **Change** | HTTP **`faction_gate_blocked`** on grid contract accept |
No new Postgres tests — gate eval is read-only against existing standing store.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Bruno prereq setup weight | Start with deny request + docs seq; extend collection with chain-complete steps only if CI/manual run needs automation | `adopted` |
| Success Bruno timing | **Defer to NEO-138** when rep grants make +15 standing reachable via HTTP quest flow | `adopted` |
| Failing rule in HTTP response | **Not in scope** — accept response keeps **`reasonCode` only**; NEO-142/NEO-140 may enrich later | `adopted` |
| Gate eval order vs prerequisites | **Prerequisites first** per backlog; player could hit **`prerequisite_incomplete`** before **`faction_gate_blocked`** | `adopted` |
**NEO-138** (rep grants) and **NEO-141** (telemetry README) follow this gate landing.

View File

@ -0,0 +1,56 @@
# Code review — NEO-137 (E7M3-05)
**Date:** 2026-06-15
**Scope:** Branch `NEO-137-e7m3-faction-gate-operations-tryaccept` — commits `1c3d075``0241cc3` vs `main`
**Base:** `main`
**Follow-up:** Review findings addressed in follow-up commits (Bruno CI fix via quest fixture API, multi-rule test, README, nits).
## Verdict
~~**Request changes**~~ **Approved after follow-up**
## Summary
This branch lands **E7M3-05**: **`FactionGateOperations.TryEvaluate`** evaluates quest **`FactionGateRule`** rows with vacuous-AND semantics (`standing >= minStanding`), wired into **`QuestStateOperations.TryAccept`** after prerequisite checks and before activation. Quest-level denies surface **`faction_gate_blocked`** via **`QuestStateReasonCodes.FactionGateBlocked`**; ops-level **`FactionGateEvaluateOutcome`** retains failing-rule context for future telemetry/HUD. **`IFactionStandingStore`** threads through **`QuestAcceptApi`** and test helpers. Coverage is solid: five unit tests, three integration tests on grid contract (deny at 0/14, success at 15), HTTP deny test, and host DI smoke. **`790`** server tests pass locally. Core server logic matches the plan and NEO-136 ops precedent. ~~One **blocking** gap: the new Bruno deny smoke runs in CI without completing **`prototype_quest_operator_chain`**, so the collection will assert the wrong **`reasonCode`** (`prerequisite_incomplete` instead of **`faction_gate_blocked`**).~~ **Done.** Bruno pre-request calls **`POST …/__dev/quest-fixture`** to mark operator chain completed without rep delivery; CI enables **`Game:EnableQuestFixtureApi`**.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-137-implementation-plan.md` | **Matches** — ops, types, wire site, reason code, tests, README, module/alignment updates; AC checklist marked complete; success Bruno deferred to NEO-138 per kickoff. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-05 scope (quest accept gate eval, no HTTP standing read, no client counterpart). |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-05 `FactionGateOperations` landed (NEO-137). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-05 / NEO-137. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionGateRule` contract. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches**`TryAccept` gate hook site behavior aligns with module responsibilities. |
| `server/README.md` | **Matches** — new FactionGateOperations section + quest accept POST table includes `faction_gate_blocked`. |
## Blocking issues
1. ~~**Bruno CI will fail on faction-gate deny smoke.** `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` (seq **10**) asserts `reasonCode === "faction_gate_blocked"`, but the quest-progress collection never completes **`prototype_quest_operator_chain`** before that request (prior steps only accept/deny gather/refine through seq **8**). **`TryAccept`** checks prerequisites before gate eval, so a fresh dev player receives **`prerequisite_incomplete`**, not **`faction_gate_blocked`**. `.github/workflows/dotnet.yml` runs the full Bruno collection with **`--bail`** on every push touching `bruno/neon-sprawl-server/**`. Fix options (pick one): add prerequisite Bruno steps (accept + complete operator chain, or a dev fixture seed if/when available), add a **`script:pre-request`** that marks the chain completed the way integration tests do, or defer/remove this request from the CI collection until NEO-138 makes the spine reachable. The `.bru` **docs** block acknowledges the prerequisite but the collection does not implement it.~~ **Done.** Added **`QuestFixtureApi`** (`POST …/__dev/quest-fixture`) + Bruno pre-request; CI **`Game:EnableQuestFixtureApi`**.
## Suggestions
1. ~~**Multi-rule AND unit test.** Prototype grid contract has one gate today; add a **`TryEvaluate`** test with two rules (one pass, one fail) to lock AND semantics if content adds multi-faction gates before E4.M1 travel gates reuse the ops.~~ **Done.** `TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList` in `FactionGateOperationsTests.cs`.
2. ~~**Bruno seq documentation in README.** `server/README.md` notes success Bruno is deferred to NEO-138; add one line that deny Bruno requires operator-chain-complete setup (or list the intended seq after prerequisite steps land) so manual runs do not surprise operators.~~ **Done.** FactionGateOperations README section documents seq **10** + quest-fixture pre-request.
## Nits
- ~~Nit: **`TryAccept`** maps all gate failures to **`faction_gate_blocked`**, discarding ops-level **`unknown_faction`** from the standing store — intentional per plan (quest-level code only in HTTP), but worth a one-line XML remark on the gate deny branch if NEO-141 telemetry needs to distinguish store vs threshold denies later.~~ **Done.** Comment on gate deny branch in `QuestStateOperations.cs`.
- ~~Nit: **`FactionGateOperationsTests`** host DI smoke asserts **`gate_blocked`** at standing 0 without seeding standing — correct behavior, but a brief comment that missing standing reads as **0** would aid future readers.~~ **Done.** Arrange comment in host DI smoke test.
## Verification
```bash
# Full server suite (author reported 790 green)
dotnet test NeonSprawl.sln
# Faction-gate-focused filter
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~FactionGate|FullyQualifiedName~GridContract"
# Bruno (will fail on seq 10 until prerequisite setup is added)
cd bruno/neon-sprawl-server
npx --yes @usebruno/cli@3.3.0 run quest-progress --env Local --sandbox=developer --bail
```
Manual: after fixing Bruno prerequisites, run **`Accept grid contract faction gate deny.bru`** only when **`prototype_quest_operator_chain`** is **completed** and Grid Operators standing is **&lt; 15**.

View File

@ -0,0 +1,133 @@
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_ShouldDeny_WhenSecondRuleFailsInAndList()
{
// Arrange
var standingStore = CreateStandingStore();
const string rustFactionId = "prototype_faction_rust_collective";
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
var rules = new[]
{
new FactionGateRuleRow(GridFactionId, GridMinStanding),
new FactionGateRuleRow(rustFactionId, 1),
};
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
Assert.Equal(rustFactionId, outcome.FailingFactionId);
Assert.Equal(1, outcome.RequiredMinStanding);
Assert.Equal(0, 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 — missing standing row reads as 0 (neutral), below grid contract minStanding 15.
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);
}
}

View File

@ -3,6 +3,7 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests
private const string PlayerId = "dev-local-1"; private const string PlayerId = "dev-local-1";
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
[Fact] [Fact]
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown() public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>(); var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>(); var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>(); var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
var standingStore = services.GetRequiredService<IFactionStandingStore>();
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept( Assert.True(QuestStateOperations.TryAccept(
PlayerId, PlayerId,
@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
timeProvider).Success); timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId, PlayerId,
@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode); Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
Assert.Null(body.Quest); 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);
}
} }

View File

@ -0,0 +1,130 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public sealed class QuestFixtureApiTests
{
private const string DevPlayer = "dev-local-1";
private const string FixturePath = $"/game/players/{DevPlayer}/__dev/quest-fixture";
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private static QuestFixtureRequest ValidFixture(params string[] completedQuestIds) =>
new()
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
CompletedQuestIds = completedQuestIds,
};
[Fact]
public async Task PostQuestFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.WithWebHostBuilder(b =>
{
b.UseEnvironment("Production");
b.UseSetting("Game:EnableQuestFixtureApi", "false");
}).CreateClient();
// Act
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
FixturePath,
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = new QuestFixtureRequest
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion + 99,
CompletedQuestIds = [ChainQuestId],
};
// Act
var response = await client.PostAsJsonAsync(FixturePath, request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/__dev/quest-fixture",
ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldMarkOperatorChainCompleted_WhenNotStarted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
// Act
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestFixtureResponse>();
Assert.NotNull(body);
Assert.True(body!.Applied);
Assert.True(progressStore.TryGetProgress(DevPlayer, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.NotNull(progress.CompletedAt);
}
[Fact]
public async Task PostQuestFixture_ShouldBeIdempotent_WhenQuestAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var first = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
// Act
var second = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) => private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterCompleteEventStore>(), services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests
IEncounterCompleteEventStore CompleteEventStore, IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -3,6 +3,7 @@ using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -400,6 +401,7 @@ public sealed class QuestProgressApiTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
@ -493,6 +495,7 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IEncounterCompleteEventStore>(), services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -515,5 +518,6 @@ public sealed class QuestProgressApiTests
IEncounterCompleteEventStore CompleteEventStore, IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; 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] [Fact]
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted() public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests
Assert.True(canWrite); 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) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{ {
var total = 0; var total = 0;
@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore ReputationDeltaStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -78,6 +78,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:FactionsDirectory", factionsDir); builder.UseSetting("Content:FactionsDirectory", factionsDir);
builder.UseSetting("Content:QuestsDirectory", questsDir); builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {

View File

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

View File

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

View File

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

View File

@ -16,6 +16,9 @@ public sealed class GamePositionOptions
/// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary> /// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary>
public bool EnableCombatTargetFixtureApi { get; set; } public bool EnableCombatTargetFixtureApi { get; set; }
/// <summary>When true, maps <c>POST …/__dev/quest-fixture</c> for Bruno/manual QA quest progress setup (also enabled in Development).</summary>
public bool EnableQuestFixtureApi { get; set; }
/// <summary>World position for the dev player at process start.</summary> /// <summary>World position for the dev player at process start.</summary>
public DefaultPositionOptions DefaultPosition { get; set; } = new(); public DefaultPositionOptions DefaultPosition { get; set; } = new();

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -18,7 +19,8 @@ public static class QuestAcceptApi
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) =>
{ {
if (body is not null && if (body is not null &&
body.SchemaVersion != 0 && body.SchemaVersion != 0 &&
@ -46,6 +48,7 @@ public static class QuestAcceptApi
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
timeProvider); timeProvider);
return Results.Json(MapResponse(trimmedId, deliveryStore, result)); return Results.Json(MapResponse(trimmedId, deliveryStore, result));

View File

@ -0,0 +1,42 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Dev-only route to seed quest progress for Bruno and manual QA (NEO-137).</summary>
public static class QuestFixtureApi
{
public static WebApplication MapQuestFixtureApi(this WebApplication app)
{
app.MapPost(
"/game/players/{id}/__dev/quest-fixture",
(string id, QuestFixtureRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
if (!QuestFixtureOperations.TryApply(
trimmedId,
body,
questRegistry,
progressStore,
timeProvider))
{
return Results.NotFound();
}
return Results.Json(new QuestFixtureResponse { Applied = true });
});
return app;
}
}

View File

@ -0,0 +1,18 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/quest-fixture</c> (NEO-137 Bruno/manual QA).</summary>
public sealed class QuestFixtureRequest
{
public const int CurrentSchemaVersion = 1;
public int SchemaVersion { get; init; }
/// <summary>Quest ids to mark completed via store activate+complete (no reward delivery).</summary>
public IReadOnlyList<string> CompletedQuestIds { get; init; } = [];
}
/// <summary>POST response for quest fixture apply.</summary>
public sealed class QuestFixtureResponse
{
public bool Applied { get; init; }
}

View File

@ -0,0 +1,68 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
public static class QuestFixtureOperations
{
/// <summary>
/// Marks each quest completed via store activate + mark-complete (no reward delivery).
/// Idempotent when a quest is already completed.
/// </summary>
public static bool TryApply(
string playerId,
QuestFixtureRequest body,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
TimeProvider timeProvider)
{
if (body.CompletedQuestIds.Count == 0)
{
return true;
}
if (!progressStore.CanWritePlayer(playerId))
{
return false;
}
foreach (var questId in body.CompletedQuestIds)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
{
return false;
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing) &&
existing.Status == QuestProgressStatus.Completed)
{
continue;
}
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out _))
{
if (!progressStore.TryActivate(playerId, normalizedQuestId, out _))
{
return false;
}
}
if (progressStore.TryMarkComplete(
playerId,
normalizedQuestId,
timeProvider.GetUtcNow(),
out _))
{
continue;
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
afterAttempt.Status == QuestProgressStatus.Completed)
{
continue;
}
return false;
}
return true;
}
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Rewards;
@ -29,6 +30,7 @@ public static class QuestStateOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -61,6 +63,16 @@ public static class QuestStateOperations
} }
} }
var gate = FactionGateOperations.TryEvaluate(
playerId,
definition.FactionGateRules,
standingStore);
if (!gate.Success)
{
// Quest-level deny always surfaces faction_gate_blocked (ops may carry unknown_faction for telemetry).
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
}
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot)) if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
{ {
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` --- // --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---

View File

@ -16,4 +16,6 @@ public static class QuestStateReasonCodes
public const string NotActive = "not_active"; public const string NotActive = "not_active";
public const string InvalidStepIndex = "invalid_step_index"; public const string InvalidStepIndex = "invalid_step_index";
public const string FactionGateBlocked = "faction_gate_blocked";
} }

View File

@ -100,6 +100,13 @@ if (app.Environment.IsDevelopment() ||
app.MapCombatTargetFixtureApi(); app.MapCombatTargetFixtureApi();
} }
if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") ||
app.Configuration.GetValue<bool>("Game:EnableQuestFixtureApi"))
{
app.MapQuestFixtureApi();
}
app.MapTargetingApi(); app.MapTargetingApi();
app.MapHotbarLoadoutApi(); app.MapHotbarLoadoutApi();
app.MapCooldownSnapshotApi(); app.MapCooldownSnapshotApi();

View File

@ -7,6 +7,7 @@
}, },
"Game": { "Game": {
"EnableMasteryFixtureApi": true, "EnableMasteryFixtureApi": true,
"EnableCombatTargetFixtureApi": true "EnableCombatTargetFixtureApi": true,
"EnableQuestFixtureApi": true
} }
} }

View File

@ -96,6 +96,20 @@ Game code applies standing changes through **`ReputationOperations.TryApplyDelta
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md). 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. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** to mark operator chain completed without reward delivery (standing **0**).
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** and **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed. **400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` pre-request.
## Resource-node catalog (`content/resource-nodes`, NEO-58) ## 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. 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 +295,14 @@ Sample default response (no quests accepted):
### Quest accept POST (NEO-120) ### 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). 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 | | Outcome | `accepted` | `reasonCode` | `quest` row |
|---------|------------|--------------|-------------| |---------|------------|--------------|-------------|
| Success | `true` | omitted | active snapshot | | 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 | | 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). Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).