14 KiB
NEO-137 — E7M3-05: FactionGateOperations + wire into TryAccept
Linear: NEO-137
Branch: NEO-137-e7m3-faction-gate-operations-tryaccept
Backlog: E7M3-pre-production-backlog.md — E7M3-05
Module: E7_M3_FactionReputationLedger.md
Pattern: NEO-136 — static ops + structured outcome + reason codes; NEO-117 — QuestStateOperations.TryAccept deny path + QuestStateReasonCodes
Precursors: NEO-134 Done — FactionGateRuleRow on QuestDefRow, catalog cross-ref gates; NEO-135 Done — IFactionStandingStore.TryGetStanding (landed on main)
Blocks: NEO-141 (telemetry hook sites — comment-only consolidation)
Client counterpart: 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 viaIFactionStandingStore.TryGetStandingper rule.FactionGateEvaluateOutcome(+ optionalFactionGateEvaluateReasonCodesif needed beyond quest-level code).- Wire into
QuestStateOperations.TryAcceptafter prerequisites, before activation. QuestStateReasonCodes.FactionGateBlocked=faction_gate_blocked.- Pass
IFactionStandingStorethroughTryAccept,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_contracton 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.mdFactionGateOperationssection.
Out of scope:
reputationGrantsdelivery (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 — surfaces faction_gate_blocked on accept feedback (requires NEO-139/NEO-140 HTTP).
Acceptance criteria checklist
- Accept returns
faction_gate_blockedwhen standing belowminStanding. - Accept succeeds when all rules satisfied.
dotnet testcovers 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+QuestAcceptApiinjectIFactionStandingStore;QuestStateReasonCodes.FactionGateBlocked. - Tests:
FactionGateOperationsTests(5 cases), grid contract integration inQuestStateOperationsTests+QuestAcceptApiTests(790 tests green). - Bruno: deny-only
Accept grid contract faction gate deny.bru; success deferred to NEO-138. - Docs:
server/README.mdFactionGateOperations 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
public static FactionGateEvaluateOutcome TryEvaluate(
string playerId,
IReadOnlyList<FactionGateRuleRow> rules,
IFactionStandingStore standingStore)
Flow:
- Normalize
playerIdviaFactionStandingIds. - Empty rules ⇒ success immediately (vacuous AND).
- For each rule in order:
- Normalize
rule.FactionId. standingStore.TryGetStanding(playerId, factionId)— on store deny, return outcome with storeReasonCode(defense-in-depthunknown_faction).- If
standing < rule.MinStanding, return deny with failing rule context and ops reasongate_blocked.
- Normalize
- 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:
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.