neon-sprawl/docs/plans/NEO-137-implementation-plan.md

13 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.mdE7M3-05
Module: E7_M3_FactionReputationLedger.md
Pattern: NEO-136 — static ops + structured outcome + reason codes; NEO-117QuestStateOperations.TryAccept deny path + QuestStateReasonCodes
Precursors: NEO-134 DoneFactionGateRuleRow on QuestDefRow, catalog cross-ref gates; NEO-135 DoneIFactionStandingStore.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 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 — 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

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:

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.