Merge pull request #179 from ViPro-Technologies/NEO-139-e7m3-get-faction-standing-http-read

NEO-139: GET faction-standing HTTP read (E7M3-07)
pull/180/head
VinPropane 2026-06-16 17:56:04 -04:00 committed by GitHub
commit 72668e1cab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 683 additions and 3 deletions

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

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

View File

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

View File

@ -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,
};

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -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<FactionStandingSnapshotResponse>();
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<FactionStandingSnapshotResponse>();
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<FactionStandingSnapshotResponse>();
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<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
}
private sealed record RewardRouterTestDependencies(
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
}

View File

@ -0,0 +1,99 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Maps <c>GET /game/players/{{id}}/faction-standing</c> — read snapshot (NEO-139).</summary>
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<FactionStandingRowJson>(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);
}

View File

@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>JSON body for <c>GET /game/players/{{id}}/faction-standing</c> (NEO-139).</summary>
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; }
/// <summary>One row per catalog faction id. Consumers must treat this as unordered and key rows by <see cref="FactionStandingRowJson.Id"/> — array order is not part of the public contract.</summary>
[JsonPropertyName("factions")]
public required IReadOnlyList<FactionStandingRowJson> Factions { get; init; }
}
/// <summary>Current standing for one faction (missing store row reads as neutral <c>0</c>).</summary>
public sealed class FactionStandingRowJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("standing")]
public int Standing { get; init; }
}

View File

@ -82,6 +82,7 @@ app.MapPlayerInventoryApi();
app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();
app.MapGigProgressionSnapshotApi();
app.MapFactionStandingApi();
app.MapEncounterProgressApi();
app.MapQuestProgressApi();
app.MapQuestAcceptApi();

View File

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