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 {
|
meta {
|
||||||
name: faction-standing
|
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 {
|
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.
|
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.
|
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",
|
"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) {
|
async function assertAllPrototypeQuestsNotStarted(bru) {
|
||||||
const { baseUrl, playerId } = resolveConfig(bru);
|
const { baseUrl, playerId } = resolveConfig(bru);
|
||||||
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
|
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
|
||||||
|
|
@ -91,7 +110,9 @@ module.exports = {
|
||||||
resetAllPrototypeQuestProgress,
|
resetAllPrototypeQuestProgress,
|
||||||
resetGatherIntroQuestProgress,
|
resetGatherIntroQuestProgress,
|
||||||
resetGatherIntroSpine,
|
resetGatherIntroSpine,
|
||||||
|
resetPrototypeFactionStanding,
|
||||||
clearInventory,
|
clearInventory,
|
||||||
assertAllPrototypeQuestsNotStarted,
|
assertAllPrototypeQuestsNotStarted,
|
||||||
ALL_PROTOTYPE_QUEST_IDS,
|
ALL_PROTOTYPE_QUEST_IDS,
|
||||||
|
PROTOTYPE_FACTION_IDS,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
| **Module ID** | E7.M3 |
|
| **Module ID** | E7.M3 |
|
||||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||||
| **Stage target** | Pre-production |
|
| **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) |
|
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
|
||||||
|
|
||||||
## Purpose
|
## 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.MapPlayerCraftApi();
|
||||||
app.MapSkillProgressionSnapshotApi();
|
app.MapSkillProgressionSnapshotApi();
|
||||||
app.MapGigProgressionSnapshotApi();
|
app.MapGigProgressionSnapshotApi();
|
||||||
|
app.MapFactionStandingApi();
|
||||||
app.MapEncounterProgressApi();
|
app.MapEncounterProgressApi();
|
||||||
app.MapQuestProgressApi();
|
app.MapQuestProgressApi();
|
||||||
app.MapQuestAcceptApi();
|
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).
|
**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)
|
## 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.
|
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