# 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 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.