NEO-137: Add implementation plan for FactionGateOperations + TryAccept wiring.

pull/176/head
VinPropane 2026-06-15 21:08:26 -04:00
parent 25aba88e78
commit 1c3d075d6b
1 changed files with 200 additions and 0 deletions

View File

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