From 1c3d075d6b0149b4351a8d232fe3f58431c458ef Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 21:08:26 -0400 Subject: [PATCH] NEO-137: Add implementation plan for FactionGateOperations + TryAccept wiring. --- docs/plans/NEO-137-implementation-plan.md | 200 ++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/plans/NEO-137-implementation-plan.md diff --git a/docs/plans/NEO-137-implementation-plan.md b/docs/plans/NEO-137-implementation-plan.md new file mode 100644 index 0000000..4677d2a --- /dev/null +++ b/docs/plans/NEO-137-implementation-plan.md @@ -0,0 +1,200 @@ +# 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 + +- [ ] 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. + +## 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.