diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f6cca95..aea996d 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -93,6 +93,7 @@ jobs: ConnectionStrings__NeonSprawl: >- Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev Game__EnableCombatTargetFixtureApi: "true" + Game__EnableQuestFixtureApi: "true" run: | set -euo pipefail dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ diff --git a/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru b/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru new file mode 100644 index 0000000..0f7d7d0 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru @@ -0,0 +1,56 @@ +meta { + name: Accept grid contract faction gate deny + type: http + seq: 10 +} + +docs { + NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15. + Pre-request marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing stays 0). + Success Bruno deferred to NEO-138 when operator-chain rep grants land. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + const chainQuestId = "prototype_quest_operator_chain"; + + const fixture = await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, + { + schemaVersion: 1, + completedQuestIds: [chainQuestId], + }, + { ...jsonHeaders, validateStatus: () => true }, + ); + if (fixture.status !== 200 || fixture.data?.applied !== true) { + throw new Error( + `quest-fixture failed: HTTP ${fixture.status} ${JSON.stringify(fixture.data)}`, + ); + } +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_grid_contract/accept + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1 + } +} + +tests { + test("denies accept with faction_gate_blocked when standing below minStanding", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.accepted).to.equal(false); + expect(body.reasonCode).to.equal("faction_gate_blocked"); + expect(body.quest).to.equal(undefined); + }); +} diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 0723166..21f35aa 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Pre-production | -| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-05** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | +| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-06** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 23311ec..0594edf 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,7 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | -| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05+** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | +| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06+** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | --- diff --git a/docs/plans/NEO-137-implementation-plan.md b/docs/plans/NEO-137-implementation-plan.md new file mode 100644 index 0000000..e10e33c --- /dev/null +++ b/docs/plans/NEO-137-implementation-plan.md @@ -0,0 +1,210 @@ +# 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. diff --git a/docs/reviews/2026-06-15-NEO-137.md b/docs/reviews/2026-06-15-NEO-137.md new file mode 100644 index 0000000..7e929ab --- /dev/null +++ b/docs/reviews/2026-06-15-NEO-137.md @@ -0,0 +1,56 @@ +# Code review — NEO-137 (E7M3-05) + +**Date:** 2026-06-15 +**Scope:** Branch `NEO-137-e7m3-faction-gate-operations-tryaccept` — commits `1c3d075` … `0241cc3` vs `main` +**Base:** `main` +**Follow-up:** Review findings addressed in follow-up commits (Bruno CI fix via quest fixture API, multi-rule test, README, nits). + +## Verdict + +~~**Request changes**~~ **Approved after follow-up** + +## Summary + +This branch lands **E7M3-05**: **`FactionGateOperations.TryEvaluate`** evaluates quest **`FactionGateRule`** rows with vacuous-AND semantics (`standing >= minStanding`), wired into **`QuestStateOperations.TryAccept`** after prerequisite checks and before activation. Quest-level denies surface **`faction_gate_blocked`** via **`QuestStateReasonCodes.FactionGateBlocked`**; ops-level **`FactionGateEvaluateOutcome`** retains failing-rule context for future telemetry/HUD. **`IFactionStandingStore`** threads through **`QuestAcceptApi`** and test helpers. Coverage is solid: five unit tests, three integration tests on grid contract (deny at 0/14, success at 15), HTTP deny test, and host DI smoke. **`790`** server tests pass locally. Core server logic matches the plan and NEO-136 ops precedent. ~~One **blocking** gap: the new Bruno deny smoke runs in CI without completing **`prototype_quest_operator_chain`**, so the collection will assert the wrong **`reasonCode`** (`prerequisite_incomplete` instead of **`faction_gate_blocked`**).~~ **Done.** Bruno pre-request calls **`POST …/__dev/quest-fixture`** to mark operator chain completed without rep delivery; CI enables **`Game:EnableQuestFixtureApi`**. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-137-implementation-plan.md` | **Matches** — ops, types, wire site, reason code, tests, README, module/alignment updates; AC checklist marked complete; success Bruno deferred to NEO-138 per kickoff. | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-05 scope (quest accept gate eval, no HTTP standing read, no client counterpart). | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-05 `FactionGateOperations` landed (NEO-137). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-05 / NEO-137. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionGateRule` contract. | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — `TryAccept` gate hook site behavior aligns with module responsibilities. | +| `server/README.md` | **Matches** — new FactionGateOperations section + quest accept POST table includes `faction_gate_blocked`. | + +## Blocking issues + +1. ~~**Bruno CI will fail on faction-gate deny smoke.** `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` (seq **10**) asserts `reasonCode === "faction_gate_blocked"`, but the quest-progress collection never completes **`prototype_quest_operator_chain`** before that request (prior steps only accept/deny gather/refine through seq **8**). **`TryAccept`** checks prerequisites before gate eval, so a fresh dev player receives **`prerequisite_incomplete`**, not **`faction_gate_blocked`**. `.github/workflows/dotnet.yml` runs the full Bruno collection with **`--bail`** on every push touching `bruno/neon-sprawl-server/**`. Fix options (pick one): add prerequisite Bruno steps (accept + complete operator chain, or a dev fixture seed if/when available), add a **`script:pre-request`** that marks the chain completed the way integration tests do, or defer/remove this request from the CI collection until NEO-138 makes the spine reachable. The `.bru` **docs** block acknowledges the prerequisite but the collection does not implement it.~~ **Done.** Added **`QuestFixtureApi`** (`POST …/__dev/quest-fixture`) + Bruno pre-request; CI **`Game:EnableQuestFixtureApi`**. + +## Suggestions + +1. ~~**Multi-rule AND unit test.** Prototype grid contract has one gate today; add a **`TryEvaluate`** test with two rules (one pass, one fail) to lock AND semantics if content adds multi-faction gates before E4.M1 travel gates reuse the ops.~~ **Done.** `TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList` in `FactionGateOperationsTests.cs`. +2. ~~**Bruno seq documentation in README.** `server/README.md` notes success Bruno is deferred to NEO-138; add one line that deny Bruno requires operator-chain-complete setup (or list the intended seq after prerequisite steps land) so manual runs do not surprise operators.~~ **Done.** FactionGateOperations README section documents seq **10** + quest-fixture pre-request. + +## Nits + +- ~~Nit: **`TryAccept`** maps all gate failures to **`faction_gate_blocked`**, discarding ops-level **`unknown_faction`** from the standing store — intentional per plan (quest-level code only in HTTP), but worth a one-line XML remark on the gate deny branch if NEO-141 telemetry needs to distinguish store vs threshold denies later.~~ **Done.** Comment on gate deny branch in `QuestStateOperations.cs`. +- ~~Nit: **`FactionGateOperationsTests`** host DI smoke asserts **`gate_blocked`** at standing 0 without seeding standing — correct behavior, but a brief comment that missing standing reads as **0** would aid future readers.~~ **Done.** Arrange comment in host DI smoke test. + +## Verification + +```bash +# Full server suite (author reported 790 green) +dotnet test NeonSprawl.sln + +# Faction-gate-focused filter +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~FactionGate|FullyQualifiedName~GridContract" + +# Bruno (will fail on seq 10 until prerequisite setup is added) +cd bruno/neon-sprawl-server +npx --yes @usebruno/cli@3.3.0 run quest-progress --env Local --sandbox=developer --bail +``` + +Manual: after fixing Bruno prerequisites, run **`Accept grid contract faction gate deny.bru`** only when **`prototype_quest_operator_chain`** is **completed** and Grid Operators standing is **< 15**. diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs new file mode 100644 index 0000000..a1b1a2b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public sealed class FactionGateOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding; + + [Fact] + public void TryEvaluate_ShouldPass_WhenRulesEmpty() + { + // Arrange + var standingStore = CreateStandingStore(); + // Act + var outcome = FactionGateOperations.TryEvaluate(PlayerId, [], standingStore); + // Assert + Assert.True(outcome.Success); + Assert.Null(outcome.ReasonCode); + Assert.Null(outcome.FailingFactionId); + } + + [Fact] + public void TryEvaluate_ShouldPass_WhenStandingAtMin() + { + // Arrange + var standingStore = CreateStandingStore(); + Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success); + var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) }; + // Act + var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); + // Assert + Assert.True(outcome.Success); + Assert.Null(outcome.ReasonCode); + } + + [Fact] + public void TryEvaluate_ShouldDeny_WhenStandingBelowMin() + { + // Arrange + var standingStore = CreateStandingStore(); + Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding - 1).Success); + var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) }; + // Act + var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode); + Assert.Equal(GridFactionId, outcome.FailingFactionId); + Assert.Equal(GridMinStanding, outcome.RequiredMinStanding); + Assert.Equal(GridMinStanding - 1, outcome.CurrentStanding); + } + + [Fact] + public void TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList() + { + // Arrange + var standingStore = CreateStandingStore(); + const string rustFactionId = "prototype_faction_rust_collective"; + Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success); + var rules = new[] + { + new FactionGateRuleRow(GridFactionId, GridMinStanding), + new FactionGateRuleRow(rustFactionId, 1), + }; + // Act + var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode); + Assert.Equal(rustFactionId, outcome.FailingFactionId); + Assert.Equal(1, outcome.RequiredMinStanding); + Assert.Equal(0, outcome.CurrentStanding); + } + + [Fact] + public void TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies() + { + // Arrange + var standingStore = CreateStandingStore(); + const string unknownFactionId = "prototype_faction_missing"; + var rules = new[] { new FactionGateRuleRow(unknownFactionId, 0) }; + // Act + var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode); + Assert.Equal(unknownFactionId, outcome.FailingFactionId); + } + + [Fact] + public async Task Host_ShouldResolveStandingStoreForGateEval_WhenStartupSucceeds() + { + // Arrange — missing standing row reads as 0 (neutral), below grid contract minStanding 15. + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + var standingStore = factory.Services.GetRequiredService(); + var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) }; + // Act + var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode); + } + + private static InMemoryFactionStandingStore CreateStandingStore() + { + var registry = CreatePrototypeRegistry(); + var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); + return new InMemoryFactionStandingStore(options, registry); + } + + private static FactionDefinitionRegistry CreatePrototypeRegistry() + { + var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + var rustFactionId = "prototype_faction_rust_collective"; + var byId = new Dictionary(StringComparer.Ordinal) + { + [gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId], + [rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId], + }; + var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new FactionDefinitionRegistry(catalog); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs index 5df5ff9..a2029c5 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests private const string PlayerId = "dev-local-1"; private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; + private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; + private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId; [Fact] public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown() @@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests var perkUnlockEngine = services.GetRequiredService(); var perkStore = services.GetRequiredService(); var deliveryStore = services.GetRequiredService(); + var standingStore = services.GetRequiredService(); var timeProvider = TimeProvider.System; Assert.True(QuestStateOperations.TryAccept( PlayerId, @@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests perkUnlockEngine, perkStore, deliveryStore, + standingStore, timeProvider).Success); Assert.True(QuestStateOperations.TryMarkComplete( PlayerId, @@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode); Assert.Null(body.Quest); } + + [Fact] + public async Task PostQuestAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var progressStore = factory.Services.GetRequiredService(); + Assert.True(progressStore.TryActivate(PlayerId, ChainQuestId, out _)); + Assert.True(progressStore.TryMarkComplete(PlayerId, ChainQuestId, DateTimeOffset.UtcNow, out _)); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/quests/{GridContractQuestId}/accept", + JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, body.ReasonCode); + Assert.Null(body.Quest); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs new file mode 100644 index 0000000..e18efc8 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public sealed class QuestFixtureApiTests +{ + private const string DevPlayer = "dev-local-1"; + private const string FixturePath = $"/game/players/{DevPlayer}/__dev/quest-fixture"; + private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; + + private static QuestFixtureRequest ValidFixture(params string[] completedQuestIds) => + new() + { + SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion, + CompletedQuestIds = completedQuestIds, + }; + + [Fact] + public async Task PostQuestFixture_ShouldReturnNotFound_WhenRouteNotRegistered() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.WithWebHostBuilder(b => + { + b.UseEnvironment("Production"); + b.UseSetting("Game:EnableQuestFixtureApi", "false"); + }).CreateClient(); + + // Act + var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostQuestFixture_ShouldReturnBadRequest_WhenBodyNull() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + FixturePath, + new StringContent(string.Empty, Encoding.UTF8, "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostQuestFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var request = new QuestFixtureRequest + { + SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion + 99, + CompletedQuestIds = [ChainQuestId], + }; + + // Act + var response = await client.PostAsJsonAsync(FixturePath, request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostQuestFixture_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/missing-player/__dev/quest-fixture", + ValidFixture(ChainQuestId)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostQuestFixture_ShouldMarkOperatorChainCompleted_WhenNotStarted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var progressStore = factory.Services.GetRequiredService(); + + // Act + var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Applied); + Assert.True(progressStore.TryGetProgress(DevPlayer, ChainQuestId, out var progress)); + Assert.Equal(QuestProgressStatus.Completed, progress.Status); + Assert.NotNull(progress.CompletedAt); + } + + [Fact] + public async Task PostQuestFixture_ShouldBeIdempotent_WhenQuestAlreadyCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var first = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId)); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + // Act + var second = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId)); + + // Assert + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs index 518342d..530ba2a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, deps.TimeProvider); private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) => @@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests IEncounterCompleteEventStore CompleteEventStore, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index eceb0fd..2cdec63 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -400,6 +401,7 @@ public sealed class QuestProgressApiTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, deps.TimeProvider); private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => @@ -493,6 +495,7 @@ public sealed class QuestProgressApiTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -515,5 +518,6 @@ public sealed class QuestProgressApiTests IEncounterCompleteEventStore CompleteEventStore, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs index 3d39198..40dbed5 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; + private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId; + private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + + [Fact] + public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + CompleteChainPrerequisite(deps); + + // Act + var result = TryAccept(deps, GridContractQuestId); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode); + Assert.Null(result.Snapshot); + Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, GridContractQuestId, out _)); + } + + [Fact] + public async Task TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + CompleteChainPrerequisite(deps); + SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding); + + // Act + var result = TryAccept(deps, GridContractQuestId); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.NotNull(result.Snapshot); + Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); + } + + [Fact] + public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenStandingOneBelowThreshold() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + CompleteChainPrerequisite(deps); + SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding - 1); + + // Act + var result = TryAccept(deps, GridContractQuestId); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode); + } [Fact] public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted() @@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests Assert.True(canWrite); } + private static void CompleteChainPrerequisite(QuestTestDependencies deps) + { + Assert.True(deps.ProgressStore.TryActivate(PlayerId, ChainQuestId, out _)); + Assert.True(deps.ProgressStore.TryMarkComplete( + PlayerId, + ChainQuestId, + DateTimeOffset.UtcNow, + out _)); + } + + private static void SeedGridStanding(QuestTestDependencies deps, int standing) + { + if (standing == 0) + { + return; + } + + var auditStore = deps.ReputationDeltaStore; + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + standing, + $"seed-{standing}", + ReputationDeltaSourceKinds.QuestCompletion, + ChainQuestId, + deps.StandingStore, + auditStore, + deps.TimeProvider); + Assert.True(outcome.Success); + Assert.Equal(standing, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + } + private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) { var total = 0; @@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, deps.TimeProvider); private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => @@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore ReputationDeltaStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 48925a7..573332b 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -78,6 +78,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateOutcome.cs b/server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateOutcome.cs new file mode 100644 index 0000000..9996cec --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateOutcome.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-137). +public readonly record struct FactionGateEvaluateOutcome( + bool Success, + string? ReasonCode, + string? FailingFactionId, + int? RequiredMinStanding, + int? CurrentStanding); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateReasonCodes.cs b/server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateReasonCodes.cs new file mode 100644 index 0000000..3e798e5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateReasonCodes.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable deny reason codes for (NEO-137). +public static class FactionGateEvaluateReasonCodes +{ + public const string GateBlocked = "gate_blocked"; +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs b/server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs new file mode 100644 index 0000000..5d28dfa --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs @@ -0,0 +1,61 @@ +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Evaluates minimum-standing gates (NEO-137). +/// Quest accept wiring: . +/// NEO-141 telemetry hook site: deny return path below. +/// +public static class FactionGateOperations +{ + /// + /// All rules must pass (AND). Empty rules succeed immediately. + /// Compares player standing vs with standing >= minStanding. + /// + public static FactionGateEvaluateOutcome TryEvaluate( + string playerId, + IReadOnlyList rules, + IFactionStandingStore standingStore) + { + var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId); + if (rules.Count == 0) + { + return Success(); + } + + foreach (var rule in rules) + { + var factionId = FactionStandingIds.NormalizeFactionId(rule.FactionId); + var read = standingStore.TryGetStanding(normalizedPlayerId, factionId); + if (!read.Success) + { + return Deny(read.ReasonCode, factionId, rule.MinStanding, null); + } + + if (read.Standing < rule.MinStanding) + { + // --- Telemetry hook site (NEO-141): future E9.M1 catalog event `faction_gate_blocked` --- + // TODO(E9.M1): catalog emit — playerId, factionId, minStanding, currentStanding, gate context. + // No ingest or ILogger here (comments-only). + return Deny( + FactionGateEvaluateReasonCodes.GateBlocked, + factionId, + rule.MinStanding, + read.Standing); + } + } + + return Success(); + } + + private static FactionGateEvaluateOutcome Success() => + new(true, null, null, null, null); + + private static FactionGateEvaluateOutcome Deny( + string? reasonCode, + string factionId, + int minStanding, + int? currentStanding) => + new(false, reasonCode, factionId, minStanding, currentStanding); +} diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs index 4a0d345..0ae7b8a 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -16,6 +16,9 @@ public sealed class GamePositionOptions /// When true, maps POST /game/__dev/combat-targets-fixture to reset prototype dummy HP (also enabled in Development/Testing). public bool EnableCombatTargetFixtureApi { get; set; } + /// When true, maps POST …/__dev/quest-fixture for Bruno/manual QA quest progress setup (also enabled in Development). + public bool EnableQuestFixtureApi { get; set; } + /// World position for the dev player at process start. public DefaultPositionOptions DefaultPosition { get; set; } = new(); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs index 90efda5..94f7330 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -18,7 +19,8 @@ public static class QuestAcceptApi IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, - IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => + IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, + TimeProvider timeProvider) => { if (body is not null && body.SchemaVersion != 0 && @@ -46,6 +48,7 @@ public static class QuestAcceptApi perkUnlockEngine, perkStore, deliveryStore, + standingStore, timeProvider); return Results.Json(MapResponse(trimmedId, deliveryStore, result)); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs new file mode 100644 index 0000000..74ccab9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs @@ -0,0 +1,42 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Quests; + +/// Dev-only route to seed quest progress for Bruno and manual QA (NEO-137). +public static class QuestFixtureApi +{ + public static WebApplication MapQuestFixtureApi(this WebApplication app) + { + app.MapPost( + "/game/players/{id}/__dev/quest-fixture", + (string id, QuestFixtureRequest? body, IPositionStateStore positions, + IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, + TimeProvider timeProvider) => + { + if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + if (!QuestFixtureOperations.TryApply( + trimmedId, + body, + questRegistry, + progressStore, + timeProvider)) + { + return Results.NotFound(); + } + + return Results.Json(new QuestFixtureResponse { Applied = true }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs new file mode 100644 index 0000000..c6d460a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs @@ -0,0 +1,18 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// POST body for POST /game/players/{{id}}/__dev/quest-fixture (NEO-137 Bruno/manual QA). +public sealed class QuestFixtureRequest +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } + + /// Quest ids to mark completed via store activate+complete (no reward delivery). + public IReadOnlyList CompletedQuestIds { get; init; } = []; +} + +/// POST response for quest fixture apply. +public sealed class QuestFixtureResponse +{ + public bool Applied { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs new file mode 100644 index 0000000..12ce7fd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs @@ -0,0 +1,68 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA). +public static class QuestFixtureOperations +{ + /// + /// Marks each quest completed via store activate + mark-complete (no reward delivery). + /// Idempotent when a quest is already completed. + /// + public static bool TryApply( + string playerId, + QuestFixtureRequest body, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore, + TimeProvider timeProvider) + { + if (body.CompletedQuestIds.Count == 0) + { + return true; + } + + if (!progressStore.CanWritePlayer(playerId)) + { + return false; + } + + foreach (var questId in body.CompletedQuestIds) + { + if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId)) + { + return false; + } + + if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing) && + existing.Status == QuestProgressStatus.Completed) + { + continue; + } + + if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out _)) + { + if (!progressStore.TryActivate(playerId, normalizedQuestId, out _)) + { + return false; + } + } + + if (progressStore.TryMarkComplete( + playerId, + normalizedQuestId, + timeProvider.GetUtcNow(), + out _)) + { + continue; + } + + if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) && + afterAttempt.Status == QuestProgressStatus.Completed) + { + continue; + } + + return false; + } + + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs index 658de66..e4f28a5 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Rewards; @@ -29,6 +30,7 @@ public static class QuestStateOperations PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || @@ -61,6 +63,16 @@ public static class QuestStateOperations } } + var gate = FactionGateOperations.TryEvaluate( + playerId, + definition.FactionGateRules, + standingStore); + if (!gate.Success) + { + // Quest-level deny always surfaces faction_gate_blocked (ops may carry unknown_faction for telemetry). + return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked); + } + if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot)) { // --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` --- diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs index 44eb53f..43fc59b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs @@ -16,4 +16,6 @@ public static class QuestStateReasonCodes public const string NotActive = "not_active"; public const string InvalidStepIndex = "invalid_step_index"; + + public const string FactionGateBlocked = "faction_gate_blocked"; } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index f400be5..0183e8f 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -100,6 +100,13 @@ if (app.Environment.IsDevelopment() || app.MapCombatTargetFixtureApi(); } +if (app.Environment.IsDevelopment() || + app.Environment.IsEnvironment("Testing") || + app.Configuration.GetValue("Game:EnableQuestFixtureApi")) +{ + app.MapQuestFixtureApi(); +} + app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi(); diff --git a/server/NeonSprawl.Server/appsettings.Development.json b/server/NeonSprawl.Server/appsettings.Development.json index ceb318d..a6aaa2c 100644 --- a/server/NeonSprawl.Server/appsettings.Development.json +++ b/server/NeonSprawl.Server/appsettings.Development.json @@ -7,6 +7,7 @@ }, "Game": { "EnableMasteryFixtureApi": true, - "EnableCombatTargetFixtureApi": true + "EnableCombatTargetFixtureApi": true, + "EnableQuestFixtureApi": true } } diff --git a/server/README.md b/server/README.md index 1aedd94..c299a40 100644 --- a/server/README.md +++ b/server/README.md @@ -96,6 +96,20 @@ Game code applies standing changes through **`ReputationOperations.TryApplyDelta Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md). +## FactionGateOperations (NEO-137) + +Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperations.TryEvaluate`** before activation. Wired from **`QuestStateOperations.TryAccept`** after prerequisite checks and before **`TryActivate`**. + +**Semantics:** all rules must pass (AND). Empty rule list succeeds immediately. Each rule compares player standing from **`IFactionStandingStore.TryGetStanding`** against **`minStanding`** with **`standing >= minStanding`**. Unknown faction ids fail closed via the standing store boundary (startup catalog cross-ref prevents bad content at load). + +**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies). + +**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. Rep grants that reach +15 standing land in NEO-138; success Bruno smoke deferred until then. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** to mark operator chain completed without reward delivery (standing **0**). + +Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md). + +**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** and **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed. **400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` pre-request. + ## Resource-node catalog (`content/resource-nodes`, NEO-58) On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. @@ -281,14 +295,14 @@ Sample default response (no quests accepted): ### Quest accept POST (NEO-120) -**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted). +**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted). Faction-gated quests (NEO-137) deny with **`faction_gate_blocked`** when standing is below any **`FactionGateRule.minStanding`** after prerequisites pass. Response (`schemaVersion` **1**): **`accepted`** (`true`/`false`), optional **`reasonCode`** on deny, optional **`quest`** row (same shape as GET list entries). Structured denies return **HTTP 200** with **`accepted: false`** (inventory/craft precedent). Accept-time **`inventory_has_item`** wiring runs inside **`TryAccept`** (NEO-118). | Outcome | `accepted` | `reasonCode` | `quest` row | |---------|------------|--------------|-------------| | Success | `true` | omitted | active snapshot | -| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete` | omitted | +| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete`, `faction_gate_blocked` | omitted | | Deny (existing row) | `false` | e.g. `already_active`, `already_completed` | current snapshot | Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).