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
commit
72668e1cab
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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**.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ app.MapPlayerInventoryApi();
|
|||
app.MapPlayerCraftApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapFactionStandingApi();
|
||||
app.MapEncounterProgressApi();
|
||||
app.MapQuestProgressApi();
|
||||
app.MapQuestAcceptApi();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue