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
parent
5fc780c8ab
commit
3f08812e93
|
|
@ -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,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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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