NEO-139: add GET faction-standing snapshot API with tests and Bruno.

Expose client-readable standing for all catalog factions; 404 unknown players via position gate; Bruno asserts +15 after operator chain.
pull/179/head
VinPropane 2026-06-16 17:19:50 -04:00
parent 5fc780c8ab
commit 3f08812e93
12 changed files with 386 additions and 6 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,32 @@
meta {
name: GET faction standing default
type: http
seq: 2
}
docs {
NEO-139: per-player standing snapshot for all catalog factions.
Fresh in-memory server (or reset standing) returns neutral 0 for both prototype factions.
Postgres may retain prior standing until reset — restart server or clear player_faction_standing for dev-local-1.
}
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.getVar("playerId"));
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

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

View File

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

View File

@ -49,9 +49,18 @@ Client-readable standing snapshot for all frozen factions.
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] GET returns stable JSON for prototype player after quest rep grants. - [x] GET returns stable JSON for prototype player after quest rep grants.
- [ ] Unknown player returns **404** (position gate precedent). - [x] Unknown player returns **404** (position gate precedent).
- [ ] `dotnet test` covers default snapshot, post-rep standing, and unknown-player 404. - [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` (3 cases); **819** 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 ## Technical approach

View File

@ -0,0 +1,128 @@
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_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.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi(); app.MapSkillProgressionSnapshotApi();
app.MapGigProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi();
app.MapFactionStandingApi();
app.MapEncounterProgressApi(); app.MapEncounterProgressApi();
app.MapQuestProgressApi(); app.MapQuestProgressApi();
app.MapQuestAcceptApi(); 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). **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.