diff --git a/bruno/neon-sprawl-server/faction-standing/Get faction standing after operator chain.bru b/bruno/neon-sprawl-server/faction-standing/Get faction standing after operator chain.bru new file mode 100644 index 0000000..a2ad9f4 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/Get faction standing after operator chain.bru @@ -0,0 +1,34 @@ +meta { + name: GET faction standing after operator chain + type: http + seq: 4 +} + +docs { + NEO-139: complete operator chain via HTTP quest flow (+15 Grid Operators rep), then read standing snapshot. + Pre-request runs operator-chain-quest-flow-helper (same organic path as NEO-138 grid contract accept Bruno). + See also quest-progress/Accept grid contract after operator chain.bru (seq 11). +} + +script:pre-request { + const { completeOperatorChainQuestFlow } = require("./scripts/operator-chain-quest-flow-helper"); + await completeOperatorChainQuestFlow(bru); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/faction-standing + body: none + auth: none +} + +tests { + test("returns Grid Operators standing 15 after operator chain completion rep grant", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.factions).to.be.an("array"); + const byId = Object.fromEntries(body.factions.map((row) => [row.id, row.standing])); + expect(byId["prototype_faction_grid_operators"]).to.equal(15); + expect(byId["prototype_faction_rust_collective"]).to.equal(0); + }); +} diff --git a/bruno/neon-sprawl-server/faction-standing/Get faction standing default.bru b/bruno/neon-sprawl-server/faction-standing/Get faction standing default.bru new file mode 100644 index 0000000..3144d25 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/Get faction standing default.bru @@ -0,0 +1,36 @@ +meta { + name: GET faction standing default + type: http + seq: 2 +} + +docs { + NEO-139: per-player standing snapshot for all catalog factions. + Pre-request clears prototype faction standing via POST …/__dev/quest-fixture resetFactionIds so re-runs stay neutral after seq 4 or quest-progress operator-chain flows. +} + +script:pre-request { + const { resetPrototypeFactionStanding } = require("./scripts/bruno-dev-fixture-helper"); + await resetPrototypeFactionStanding(bru); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/faction-standing + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1 and two prototype factions at neutral standing", function () { + expect(res.getStatus()).to.equal(200); + expect(res.getHeader("content-type")).to.contain("application/json"); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId") || "dev-local-1"); + expect(body.factions).to.be.an("array"); + expect(body.factions.length).to.equal(2); + const byId = Object.fromEntries(body.factions.map((row) => [row.id, row.standing])); + expect(byId["prototype_faction_grid_operators"]).to.equal(0); + expect(byId["prototype_faction_rust_collective"]).to.equal(0); + }); +} diff --git a/bruno/neon-sprawl-server/faction-standing/Get faction standing unknown player.bru b/bruno/neon-sprawl-server/faction-standing/Get faction standing unknown player.bru new file mode 100644 index 0000000..bd15fb7 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/Get faction standing unknown player.bru @@ -0,0 +1,21 @@ +meta { + name: GET faction standing unknown player + type: http + seq: 3 +} + +docs { + NEO-139: unknown player returns 404 (position gate precedent — same as GET …/position). +} + +get { + url: {{baseUrl}}/game/players/unknown-player-neo-139/faction-standing + body: none + auth: none +} + +tests { + test("returns 404 when player has no position row", function () { + expect(res.getStatus()).to.equal(404); + }); +} diff --git a/bruno/neon-sprawl-server/faction-standing/folder.bru b/bruno/neon-sprawl-server/faction-standing/folder.bru index 99cdae0..de51b0d 100644 --- a/bruno/neon-sprawl-server/faction-standing/folder.bru +++ b/bruno/neon-sprawl-server/faction-standing/folder.bru @@ -1,3 +1,8 @@ meta { name: faction-standing } + +docs { + Run seq 2 self-resets faction standing before asserting neutral defaults. + Seq 4 (operator chain) leaves Grid Operators standing at 15 — expected; re-run seq 2 anytime to verify reset + neutral read. +} diff --git a/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru b/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru index 6204585..34ef6fb 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru @@ -6,7 +6,8 @@ meta { docs { NEO-138: complete operator chain via HTTP quest flow (rep grant +15 Grid Operators), then accept prototype_quest_grid_contract succeeds without manual standing seed. - Pre-request runs operator-chain-quest-flow-helper (gather/combat/refine intros + chain steps). No standing GET until NEO-139. + Pre-request runs operator-chain-quest-flow-helper (gather/combat/refine intros + chain steps). + Standing GET assertion: faction-standing/Get faction standing after operator chain.bru (seq 4). Accept auto-completes: operator-chain reward already granted survey_drone_kit, satisfying grid contract inventory_has_item objective on activation. } diff --git a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js index b998ced..89ec479 100644 --- a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js +++ b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js @@ -47,6 +47,25 @@ const ALL_PROTOTYPE_QUEST_IDS = [ "prototype_quest_refine_intro", ]; +const PROTOTYPE_FACTION_IDS = [ + "prototype_faction_grid_operators", + "prototype_faction_rust_collective", +]; + +async function resetPrototypeFactionStanding(bru) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, + { schemaVersion: 1, resetFactionIds: PROTOTYPE_FACTION_IDS }, + jsonHeaders, + ); + if (response.status !== 200 || response.data?.applied !== true) { + throw new Error( + `quest-fixture resetFactionIds failed: ${response.status} ${JSON.stringify(response.data)}`, + ); + } +} + async function assertAllPrototypeQuestsNotStarted(bru) { const { baseUrl, playerId } = resolveConfig(bru); const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, { @@ -91,7 +110,9 @@ module.exports = { resetAllPrototypeQuestProgress, resetGatherIntroQuestProgress, resetGatherIntroSpine, + resetPrototypeFactionStanding, clearInventory, assertAllPrototypeQuestsNotStarted, ALL_PROTOTYPE_QUEST_IDS, + PROTOTYPE_FACTION_IDS, }; diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index f4c564d..729439b 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)); **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)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-07** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) → **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)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-08** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) → **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 8eae8eb..c7983d4 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 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 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07+** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) → **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), [NEO-138 plan](../../plans/NEO-138-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 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08+** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) → **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), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-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-139-implementation-plan.md b/docs/plans/NEO-139-implementation-plan.md new file mode 100644 index 0000000..0124c95 --- /dev/null +++ b/docs/plans/NEO-139-implementation-plan.md @@ -0,0 +1,180 @@ +# NEO-139 — E7M3-07: GET faction-standing HTTP read + +**Linear:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) +**Branch:** `NEO-139-e7m3-get-faction-standing-http-read` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-07** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-37](NEO-37-implementation-plan.md) / [NEO-44](NEO-44-implementation-plan.md) — per-player snapshot GET + `schemaVersion`; [NEO-6](NEO-6-implementation-plan.md) — unknown player **404** via position gate +**Precursors:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore`; [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) **`Done`** — operator-chain rep grant **+15** via reward router +**Blocks:** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) (quest HTTP rep projections), [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) (Godot HUD) +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — parses **`GET …/faction-standing`**; blocked until this story lands + +## Goal + +Client-readable standing snapshot for all frozen factions. + +## Kickoff clarifications + +**No clarifications needed.** Linear AC, [E7M3-07 backlog scope](E7M3-pre-production-backlog.md#e7m3-07--get-gameplayersidfaction-standing-http-read), and snapshot-read precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Unknown player | **404** before building body | Linear AC “position gate precedent”; `SkillProgressionSnapshotApi`, `GigProgressionSnapshotApi`, `PositionStateApi` | +| Player existence gate | **`IPositionStateStore.TryGetPosition(trimmedId)`** — not `CanWritePlayer` alone | Same trio of snapshot APIs; position row is the shared “known player” contract | +| Path id handling | **Trim** path segment; echo trimmed id in JSON **`playerId`** | Skill/gig snapshot APIs (position GET additionally case-insensitive lookup — snapshot APIs use trim-only + position gate) | +| Response shape | **`schemaVersion` 1**, **`playerId`**, **`factions`** array of **`{ id, standing }`** | Backlog “lists each catalog faction with current standing (default 0)”; mirrors skill progression row minimalism (display names deferred — no `GET /game/world/faction-definitions` yet) | +| Faction roster | **One row per catalog faction** via **`IFactionDefinitionRegistry.GetDefinitionsInIdOrder()`** | Backlog “all frozen factions”; prototype roster = 2 ids (`PrototypeE7M3FactionCatalogRules`) | +| Missing standing row | **`standing: 0`** (clamped) via **`IFactionStandingStore.TryGetStanding`** per faction | NEO-135 store contract; HTTP layer does not reimplement neutral default | +| Array order | **Not part of public contract** — consumers key by **`id`** (doc on DTO) | `SkillProgressionSnapshotResponse` XML doc precedent | +| Bruno scope | **Default snapshot smoke** + **operator-chain standing assertion (+15 Grid Operators)** | Backlog Bruno folder; closes NEO-138 deferral (“No standing GET until NEO-139”) | +| Godot / quest projections | **Out of scope** | E7M3-08 (NEO-140), E7M3-10 (NEO-142) | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`GET /game/players/{id}/faction-standing`** — `FactionStandingApi` + DTOs in `Game/Factions/`. +- Response lists each catalog faction with current standing (default **0** when no row). +- Integration tests + Bruno `bruno/neon-sprawl-server/faction-standing/`. +- `server/README.md` section. +- Optional: extend **`Accept grid contract after operator chain.bru`** docs or add sibling Bruno that GETs standing after chain (assert **+15** on Grid Operators). + +**Out of scope:** + +- Godot HUD (NEO-142), quest HTTP rep/gate projections (NEO-140), telemetry ingest (NEO-141). +- **`GET /game/world/faction-definitions`** (no backlog item; client can join display names later). +- Write/mutation endpoints (standing changes stay on **`ReputationOperations`** / quest reward path). + +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — standing label + gate feedback after NEO-139/NEO-140. + +## Acceptance criteria checklist + +- [x] GET returns stable JSON for prototype player after quest rep grants. +- [x] Unknown player returns **404** (position gate precedent). +- [x] `dotnet test` covers default snapshot, post-rep standing, and unknown-player 404. + +## Implementation reconciliation (shipped) + +- **API:** `FactionStandingApi.MapFactionStandingApi` — `GET /game/players/{id}/faction-standing`. +- **DTOs:** `FactionStandingSnapshotResponse`, `FactionStandingRowJson` (`schemaVersion` 1). +- **Gate:** `IPositionStateStore.TryGetPosition` → **404** for unknown player. +- **Tests:** `FactionStandingApiTests` (5 cases: 404 unknown/whitespace-only, trim-path echo, defaults, post-rep); **821** tests green. +- **Bruno:** `Get faction standing default.bru`, `unknown player.bru`, `after operator chain.bru`. +- **Docs:** `server/README.md` Faction standing read section; E7.M3 module + alignment register updated. + +## Technical approach + +### 1. DTOs (`Game/Factions/FactionStandingSnapshotDtos.cs`) + +| Type | JSON | Notes | +|------|------|-------| +| **`FactionStandingSnapshotResponse`** | `schemaVersion`, `playerId`, `factions` | `CurrentSchemaVersion = 1` | +| **`FactionStandingRowJson`** | `id`, `standing` | One row per catalog faction | + +Document that **`factions` array order is not contractual** — key rows by **`id`**. + +### 2. `FactionStandingApi` (`Game/Factions/FactionStandingApi.cs`) + +```csharp +app.MapGet( + "/game/players/{id}/faction-standing", + (string id, IPositionStateStore positions, + IFactionDefinitionRegistry factionRegistry, + IFactionStandingStore standingStore) => { ... }); +``` + +**Flow:** + +1. `trimmedId = id.Trim()`; if empty or `!positions.TryGetPosition(trimmedId, out _)` ⇒ **`Results.NotFound()`**. +2. **`BuildSnapshot(trimmedId, factionRegistry, standingStore)`**: + - Iterate **`factionRegistry.GetDefinitionsInIdOrder()`**. + - For each definition, **`standingStore.TryGetStanding(playerId, definition.Id)`** — on success use **`outcome.Standing`**; store deny for catalog faction should be unreachable (defense: skip/500 only if violated — prefer **`Debug.Assert`** in dev; integration tests use real catalog). +3. Return **`Results.Json(response)`**. + +**`BuildSnapshot`** is **`internal static`** for unit/integration reuse (mirror **`SkillProgressionSnapshotApi.BuildSnapshot`**). + +### 3. Wiring + +| Site | Change | +|------|--------| +| **`Program.cs`** | **`app.MapFactionStandingApi()`** after faction standing store registration (near skill/gig snapshot maps) | + +### 4. Prototype test scenarios + +| Scenario | Setup | Expected | +|----------|-------|----------| +| Fresh dev player | GET **`dev-local-1`** | **200**; **`schemaVersion` 1**; **2** factions; both **`standing: 0`** | +| Unknown player | GET **`missing-player`** | **404** | +| After operator-chain rep | Deliver operator-chain completion via **`RewardRouterOperations`** or HTTP quest flow helper | Grid Operators **`standing: 15`**; Rust Collective **`standing: 0`** | +| Case on path | GET with trimmed id | **200**; body **`playerId`** echoes trimmed path (skill progression precedent) | + +Constants: **`PrototypeE7M3FactionCatalogRules.ExpectedFactionIds`**, **`PrototypeE7M3QuestFactionRules.GridContractGateFactionId`**. + +### 5. Bruno (`bruno/neon-sprawl-server/faction-standing/`) + +| Request | seq | Role | +|---------|-----|------| +| **`Health after faction standing store load.bru`** | 1 | Keep existing health smoke (NEO-135) | +| **`Get faction standing default.bru`** | 2 | **200**; 2 factions; all **`standing: 0`** on fresh dev player (document restart/reset) | +| **`Get faction standing unknown player.bru`** | 3 | **404** | +| **`Get faction standing after operator chain.bru`** | 4 | Pre-request **`operator-chain-quest-flow-helper`**; assert Grid Operators **15**, Rust Collective **0** | + +Update **`Accept grid contract after operator chain.bru`** docs to reference standing GET sibling at seq **4+**. + +### 6. README + +Add **`## Faction standing read (NEO-139)`** after standing store section: + +- Route, sample JSON (defaults + post-chain example). +- Unknown player **404**; join display names from content client-side until world API exists. +- Cross-link NEO-142 client consumer. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Factions/FactionStandingSnapshotDtos.cs` | Response + row JSON types | +| `server/NeonSprawl.Server/Game/Factions/FactionStandingApi.cs` | Route map + `BuildSnapshot` | +| `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingApiTests.cs` | Integration tests (404, defaults, post-rep) | +| `bruno/neon-sprawl-server/faction-standing/Get faction standing default.bru` | Default snapshot smoke | +| `bruno/neon-sprawl-server/faction-standing/Get faction standing unknown player.bru` | 404 smoke | +| `bruno/neon-sprawl-server/faction-standing/Get faction standing after operator chain.bru` | +15 assertion after HTTP quest flow | + +## Files to modify + +| Path | Change | +|------|--------| +| `server/NeonSprawl.Server/Program.cs` | Register **`MapFactionStandingApi()`** | +| `server/README.md` | Faction standing GET section + curl example | +| `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` | Doc cross-link to standing GET Bruno | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-07 plan kicked off (update to **landed** when shipped) | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M3 row note when shipped | + +## Tests + +| File | Coverage | +|------|----------| +| **`FactionStandingApiTests`** | Unknown player **404**; default **200** with 2 factions at **0**; after **`RewardRouterOperations`** operator-chain delivery Grid Operators **15** | +| **CI** | Existing **`dotnet test`** green; Bruno collection optional in local/CI per repo convention | + +Manual QA: **none** (server HTTP read; client verification deferred to NEO-142 / NEO-143). + +## Implementation order + +1. DTOs + **`FactionStandingApi.BuildSnapshot`**. +2. **`Program.cs`** map. +3. **`FactionStandingApiTests`** (AAA layout). +4. Bruno requests. +5. **`server/README.md`** + decomposition alignment on ship. + +## Open questions / risks + +| Item | Agent recommendation | Status | +|------|---------------------|--------| +| Store read deny for catalog faction | **Should not happen** — if it does, treat as **500** or omit row (prefer **500** to surface misconfiguration) | `adopted` — use standing outcome; test happy path only | +| Postgres mode Bruno | **Same assertions** on dev player after operator-chain flow; document DB persistence like skill progression Bruno | `adopted` | +| Extend grid-contract accept Bruno with inline GET | **Separate Bruno file** in `faction-standing/` — keeps quest-progress folder focused | `adopted` | + +## Client counterpart + +[NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — **`faction_standing_client.gd`** parses this route; requires NEO-140 for gate/rep quest row projections. diff --git a/docs/reviews/2026-06-16-NEO-139.md b/docs/reviews/2026-06-16-NEO-139.md new file mode 100644 index 0000000..1eef8d4 --- /dev/null +++ b/docs/reviews/2026-06-16-NEO-139.md @@ -0,0 +1,67 @@ +# Code review — NEO-139 (E7M3-07) + +**Date:** 2026-06-16 +**Scope:** Branch `NEO-139-e7m3-get-faction-standing-http-read` — commits `5fc780c` … `3f08812` vs `main` +**Base:** `main` + +Follow-up: suggestion + applicable nits below are **done** (strikethrough + **Done.**). + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands **E7M3-07**: **`GET /game/players/{id}/faction-standing`** returns a versioned snapshot (`schemaVersion` 1) with one row per catalog faction, gated by **`IPositionStateStore.TryGetPosition`** (unknown player **404**). **`FactionStandingApi.TryBuildSnapshot`** iterates **`IFactionDefinitionRegistry.GetDefinitionsInIdOrder()`** and reads standing via **`IFactionStandingStore.TryGetStanding`**, surfacing store read failures as **500** per the adopted plan risk table. Integration tests cover 404, neutral defaults, and post–operator-chain **+15** Grid Operators standing; Bruno adds three requests under `faction-standing/` and cross-links NEO-138 grid-contract accept docs. **`819`** server tests pass locally. Implementation mirrors skill/gig snapshot GET precedent and unblocks NEO-140/NEO-142. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-139-implementation-plan.md` | **Matches** — route, DTOs, position gate, catalog roster, Bruno folder, README, module/alignment updates, and reconciliation checklist all reflected in the diff. | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-07 server read scope; client HUD (NEO-142) and quest projections (NEO-140) explicitly deferred. | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-07 landed (NEO-139). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated with HTTP read + README/Bruno links. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. | +| `server/README.md` | **Matches** — Faction standing read section with curl, sample JSON, 404 gate, consumer notes, NEO-142 cross-link. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Add trim-path integration test.** The plan §4 prototype scenario table lists “GET with trimmed id → **200**; body **`playerId`** echoes trimmed path” (skill/gig snapshot precedent). The handler trims (`id.Trim()`) but no test exercises whitespace-padded path segments or asserts echoed **`playerId`**. A small fourth `[Fact]` would lock the contract.~~ **Done.** Added `GetFactionStanding_ShouldReturnOk_WhenPathIdHasLeadingAndTrailingWhitespace` and `GetFactionStanding_ShouldReturnNotFound_WhenPathIdIsWhitespaceOnly` in `FactionStandingApiTests.cs`. + +## Nits + +- ~~Nit: Empty or whitespace-only `{id}` returns **404** (trim → empty) but is not covered by tests — low risk given identical gate to other snapshot APIs.~~ **Done.** Whitespace-only path covered by `GetFactionStanding_ShouldReturnNotFound_WhenPathIdIsWhitespaceOnly`. +- Nit: **`BuildSnapshot`** (throws on failure) is exposed for reuse while HTTP uses **`TryBuildSnapshot`** — fine; no direct unit tests call **`BuildSnapshot`** today. +- Nit: **`FactionStandingApiTests`** duplicates a **`RewardRouterTestDependencies`** helper record also seen in reward-router tests — acceptable for now; extract only if a third caller appears. +- ~~Nit: Bruno **`Get faction standing default.bru`** (seq 2) documents Postgres/restart caveats; re-running the folder on a warm in-memory server after seq 4 can leave standing at **15** — docs already warn; optional one-line note in folder README if authors hit flaky local runs.~~ **Done.** `faction-standing/folder.bru` docs note seq 4 side effect and seq 2 re-run caveat. + +## Verification + +```bash +# Full server suite (819 green locally) +dotnet test NeonSprawl.sln + +# NEO-139-focused filter +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~FactionStandingApi" + +# Bruno faction-standing collection +cd bruno/neon-sprawl-server +npx --yes @usebruno/cli@3.3.0 run faction-standing --env Local --sandbox=developer --bail +``` + +With Postgres + dev fixtures (matches CI): + +```bash +ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev' \ +Game__EnableCombatTargetFixtureApi=true \ +Game__EnableQuestFixtureApi=true \ +Game__EnableResourceNodeFixtureApi=true \ +dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj --urls http://127.0.0.1:5253 +``` + +Manual: after operator-chain quest flow, **`GET …/faction-standing`** for **`dev-local-1`** shows Grid Operators **15**, Rust Collective **0**. diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingApiTests.cs new file mode 100644 index 0000000..bffc18b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingApiTests.cs @@ -0,0 +1,160 @@ +using System.Linq; +using System.Net; +using System.Net.Http.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; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public sealed class FactionStandingApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string OperatorChainQuestId = "prototype_quest_operator_chain"; + private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + private const string RustCollectiveFactionId = "prototype_faction_rust_collective"; + + [Fact] + public async Task GetFactionStanding_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/faction-standing"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetFactionStanding_ShouldReturnNotFound_WhenPathIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/%20%20/faction-standing"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetFactionStanding_ShouldReturnOk_WhenPathIdHasLeadingAndTrailingWhitespace() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/%20dev-local-1%20/faction-standing"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal("dev-local-1", body!.PlayerId); + Assert.Equal(FactionStandingSnapshotResponse.CurrentSchemaVersion, body.SchemaVersion); + } + + [Fact] + public async Task GetFactionStanding_ShouldReturnSchemaV1_WithNeutralStanding_ForPrototypeFactions() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/dev-local-1/faction-standing"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(FactionStandingSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + Assert.NotNull(body.Factions); + Assert.Equal(PrototypeE7M3FactionCatalogRules.ExpectedFactionIds.Count, body.Factions!.Count); + var byId = body.Factions.ToDictionary(static row => row.Id, StringComparer.Ordinal); + foreach (var factionId in PrototypeE7M3FactionCatalogRules.ExpectedFactionIds) + { + Assert.True(byId.TryGetValue(factionId, out var row)); + Assert.Equal(0, row!.Standing); + } + } + + [Fact] + public async Task GetFactionStanding_ShouldReturnGridOperatorsStanding15_AfterOperatorChainRepGrant() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveRewardRouterDependencies(factory); + var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; + var deliver = RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + OperatorChainQuestId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + TimeProvider.System); + Assert.True(deliver.Success); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/dev-local-1/faction-standing"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var byId = body!.Factions!.ToDictionary(static row => row.Id, StringComparer.Ordinal); + Assert.Equal( + PrototypeE7M3QuestFactionRules.GridContractMinStanding, + byId[GridOperatorsFactionId].Standing); + Assert.Equal(0, byId[RustCollectiveFactionId].Standing); + } + + private static RewardRouterTestDependencies ResolveRewardRouterDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new RewardRouterTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + private sealed record RewardRouterTestDependencies( + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore); +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingApi.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingApi.cs new file mode 100644 index 0000000..8b60842 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingApi.cs @@ -0,0 +1,99 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Factions; + +/// Maps GET /game/players/{{id}}/faction-standing — read snapshot (NEO-139). +public static class FactionStandingApi +{ + public static WebApplication MapFactionStandingApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/faction-standing", + (string id, IPositionStateStore positions, IFactionDefinitionRegistry factionRegistry, + IFactionStandingStore standingStore) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var snapshotResult = TryBuildSnapshot(trimmedId, factionRegistry, standingStore); + return snapshotResult.Kind switch + { + FactionStandingSnapshotBuildKind.Success => Results.Json(snapshotResult.Response), + FactionStandingSnapshotBuildKind.StoreReadFailed => + Results.Problem( + statusCode: StatusCodes.Status500InternalServerError, + title: "Faction standing read failed", + detail: snapshotResult.ReasonCode), + _ => throw new InvalidOperationException($"Unexpected build outcome: {snapshotResult.Kind}"), + }; + }); + + return app; + } + + internal static FactionStandingSnapshotResponse BuildSnapshot( + string playerId, + IFactionDefinitionRegistry factionRegistry, + IFactionStandingStore standingStore) + { + var result = TryBuildSnapshot(playerId, factionRegistry, standingStore); + if (result.Kind != FactionStandingSnapshotBuildKind.Success || result.Response is null) + { + throw new InvalidOperationException( + $"BuildSnapshot failed: {result.ReasonCode ?? result.Kind.ToString()}"); + } + + return result.Response; + } + + private static FactionStandingSnapshotBuildResult TryBuildSnapshot( + string playerId, + IFactionDefinitionRegistry factionRegistry, + IFactionStandingStore standingStore) + { + var defs = factionRegistry.GetDefinitionsInIdOrder(); + var factions = new List(defs.Count); + foreach (var definition in defs) + { + var outcome = standingStore.TryGetStanding(playerId, definition.Id); + if (!outcome.Success) + { + return new FactionStandingSnapshotBuildResult( + FactionStandingSnapshotBuildKind.StoreReadFailed, + null, + outcome.ReasonCode); + } + + factions.Add( + new FactionStandingRowJson + { + Id = definition.Id, + Standing = outcome.Standing, + }); + } + + return new FactionStandingSnapshotBuildResult( + FactionStandingSnapshotBuildKind.Success, + new FactionStandingSnapshotResponse + { + PlayerId = playerId, + Factions = factions, + SchemaVersion = FactionStandingSnapshotResponse.CurrentSchemaVersion, + }, + null); + } + + private enum FactionStandingSnapshotBuildKind + { + Success, + StoreReadFailed, + } + + private readonly record struct FactionStandingSnapshotBuildResult( + FactionStandingSnapshotBuildKind Kind, + FactionStandingSnapshotResponse? Response, + string? ReasonCode); +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingSnapshotDtos.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingSnapshotDtos.cs new file mode 100644 index 0000000..e2b6936 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingSnapshotDtos.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Factions; + +/// JSON body for GET /game/players/{{id}}/faction-standing (NEO-139). +public sealed class FactionStandingSnapshotResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// One row per catalog faction id. Consumers must treat this as unordered and key rows by — array order is not part of the public contract. + [JsonPropertyName("factions")] + public required IReadOnlyList Factions { get; init; } +} + +/// Current standing for one faction (missing store row reads as neutral 0). +public sealed class FactionStandingRowJson +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("standing")] + public int Standing { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 85c3e7c..6aa6483 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -82,6 +82,7 @@ app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi(); +app.MapFactionStandingApi(); app.MapEncounterProgressApi(); app.MapQuestProgressApi(); app.MapQuestAcceptApi(); diff --git a/server/README.md b/server/README.md index 8ea80f3..a7c416d 100644 --- a/server/README.md +++ b/server/README.md @@ -82,6 +82,32 @@ Append-only **`ReputationDelta`** audit rows live in **`IReputationDeltaStore`** **Storage:** in-memory singletons when **`ConnectionStrings:NeonSprawl`** is unset (standing store seeds configured dev player only). When Postgres is configured, **`PostgresFactionStandingStore`** persists to **`player_faction_standing`** ([`V009__player_faction_standing.sql`](../db/migrations/V009__player_faction_standing.sql)) and **`PostgresReputationDeltaStore`** appends to **`reputation_delta_audit`** ([`V010__reputation_delta_audit.sql`](../db/migrations/V010__reputation_delta_audit.sql)). Plan: [NEO-135 implementation plan](../../docs/plans/NEO-135-implementation-plan.md). +## Faction standing read (NEO-139) + +**`GET /game/players/{id}/faction-standing`** returns a versioned snapshot of current standing for every faction in the loaded catalog. Path `{id}` is **trimmed**; unknown players (no position row) return **404** before building a body — same gate as skill/gig progression reads. + +Example (dev player defaults): + +```bash +curl -s http://localhost:5253/game/players/dev-local-1/faction-standing +``` + +Sample response (neutral standing on fresh dev player): + +```json +{"schemaVersion":1,"playerId":"dev-local-1","factions":[{"id":"prototype_faction_grid_operators","standing":0},{"id":"prototype_faction_rust_collective","standing":0}]} +``` + +After **`prototype_quest_operator_chain`** completion (organic reward delivery), Grid Operators standing is **15**: + +```json +{"schemaVersion":1,"playerId":"dev-local-1","factions":[{"id":"prototype_faction_grid_operators","standing":15},{"id":"prototype_faction_rust_collective","standing":0}]} +``` + +**Consumers:** key rows by **`id`** — array order is not part of the public contract. Display names are not included; join from client content until a world faction-definitions route exists. Godot HUD: **NEO-142**. + +Bruno smoke: `bruno/neon-sprawl-server/faction-standing/`. Plan: [NEO-139 implementation plan](../../docs/plans/NEO-139-implementation-plan.md). + ## ReputationOperations (NEO-136) Game code applies standing changes through **`ReputationOperations.TryApplyDelta`** — not **`IFactionStandingStore.TryApplyStandingDelta`** alone — so every mutation records source attribution and an append-only audit row.