diff --git a/bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru b/bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru new file mode 100644 index 0000000..43ca726 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (faction standing store NEO-135) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-135 registers IFactionStandingStore and IReputationDeltaStore (in-memory or Postgres when configured). No faction standing HTTP API in this story — use this request to confirm the host started after store DI wiring. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/faction-standing/folder.bru b/bruno/neon-sprawl-server/faction-standing/folder.bru new file mode 100644 index 0000000..99cdae0 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/folder.bru @@ -0,0 +1,3 @@ +meta { + name: faction-standing +} diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index dd2a4f0..e078daf 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Pre-production | -| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-03** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) → **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`. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-04** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 6fd45f7..3a345a4 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,7 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | -| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03+** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | +| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04+** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | --- diff --git a/docs/plans/NEO-135-implementation-plan.md b/docs/plans/NEO-135-implementation-plan.md new file mode 100644 index 0000000..7f7b45b --- /dev/null +++ b/docs/plans/NEO-135-implementation-plan.md @@ -0,0 +1,220 @@ +# NEO-135 — E7M3-03: Faction standing store + reputation delta audit store + +**Linear:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) +**Branch:** `NEO-135-e7m3-faction-standing-reputation-stores` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-03** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-116-implementation-plan.md](NEO-116-implementation-plan.md) (in-memory + Postgres when configured); [NEO-126](NEO-126-implementation-plan.md) (append-only audit/event store shape) +**Precursor:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `IFactionDefinitionRegistry`, fail-fast faction catalog (**landed on `main`**) +**Blocks:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) (ReputationOperations), [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) (gate eval reads standing), [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) (HTTP read) + +## Goal + +Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log. Stores validate faction ids fail-closed at the boundary; missing standing reads as **0** (neutral) clamped to faction min/max. + +## Kickoff clarifications + +**No clarifications needed.** Linear AC, [E7M3-03 backlog scope](E7M3-pre-production-backlog.md#e7m3-03--faction-standing-store--reputation-delta-audit-store), kickoff decisions table, and NEO-116/NEO-134 precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Postgres persistence | **Include when configured** — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116 adopted same | +| Store split | **`IFactionStandingStore`** + **`IReputationDeltaStore`** | Backlog in-scope list; audit append-only separate from snapshot | +| Public apply API | **Out of scope** — store-level mutation primitives only | Backlog defers **`ReputationOperations`** to E7M3-04 / NEO-136 | +| Unknown faction deny | **Structured outcome** with stable reason code at store boundary | Linear AC; kickoff “Fail-closed \| Unknown factionId → deny” | +| Default standing | **0** when row missing; **clamp on read** to registry min/max | Backlog + kickoff standing band **-100…100**, neutral **0** | +| Clamp on write | **Store primitive clamps** resulting standing before persist | Backlog unit tests “clamp at bounds”; NEO-136 AC inherits same bounds | +| Player write gate | **`CanWritePlayer`** on standing store (dev bucket / Postgres `player_position`) | NEO-116 quest store precedent | +| Faction validation | Stores inject **`IFactionDefinitionRegistry`** — not raw string trust | Startup catalog + AC “unknown faction id” at store boundary | +| Audit row shape | player, faction, delta, resulting standing, source kind, source id, timestamp, row id | Backlog E7M3-03 in-scope bullet | +| Source kind (prototype) | **`quest_completion`** string constant only | Backlog example; E7M3-04/06 wire quest id as source id | +| DI registration | **`AddFactionStandingStores`** extension separate from catalog | NEO-116 `AddPlayerQuestStateStore` precedent | +| HTTP / Godot / gate eval | **Out of scope** | Backlog E7M3-03; NEO-137+ | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- Snapshot + audit types, id normalization, reason codes. +- **`IFactionStandingStore`** + in-memory + Postgres implementations. +- **`IReputationDeltaStore`** + in-memory + Postgres implementations. +- **`V009__player_faction_standing.sql`**, **`V010__reputation_delta_audit.sql`** when `ConnectionStrings:NeonSprawl` is set. +- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores`** wired from **`Program.cs`**. +- Unit tests (AAA): default read **0**, read clamp, first standing delta apply, clamp at min/max bounds, unknown faction deny, audit append + query, append-only semantics. +- Postgres persistence integration tests (`RequirePostgresFact`) for both stores. +- `server/README.md` faction standing + reputation delta store sections. + +**Out of scope:** + +- **`ReputationOperations.TryApplyDelta`** public orchestration (NEO-136 / E7M3-04). +- Quest accept gate evaluation (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot (NEO-142+). +- Telemetry hook comments (NEO-141). + +**Client counterpart:** none (infrastructure-only). + +## Acceptance criteria checklist + +- [x] Standing readable per player+faction; deltas append-only and queryable for audit tests. +- [x] Unknown faction id returns structured deny at store boundary. +- [x] `dotnet test` covers store gates. + +## Implementation reconciliation (shipped) + +- **Types:** `FactionStandingIds`, reason codes, read/mutation outcomes, `ReputationDeltaRow`, `ReputationDeltaSourceKinds`. +- **Stores:** `IFactionStandingStore` / `IReputationDeltaStore`; in-memory + Postgres (`V009`, `V010`) when connection string set. +- **DI:** `AddFactionStandingStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`. +- **Tests:** `InMemoryFactionStandingStoreTests`, `InMemoryReputationDeltaStoreTests`, Postgres persistence integration tests; host DI smoke (`768` tests green). +- **Docs:** `server/README.md` faction standing + reputation delta store sections. + +## Technical approach + +### 1. Types (`Game/Factions/`) + +| Type | Role | +|------|------| +| `FactionStandingIds` | Normalize `playerId` / `factionId`; composite key helper (mirror `QuestProgressIds`) | +| `FactionStandingReasonCodes` | `unknown_faction`, `player_not_writable` (`invalid_delta` deferred to NEO-136 `ReputationOperations`) | +| `FactionStandingReadOutcome` | `Success`, optional `ReasonCode`, `Standing` (clamped) | +| `FactionStandingMutationOutcome` | `Success`, optional `ReasonCode`, `PreviousStanding`, `NewStanding` | +| `ReputationDeltaSourceKinds` | `QuestCompletion = "quest_completion"` (prototype constant) | +| `ReputationDeltaRow` | Immutable audit row: `Id`, `PlayerId`, `FactionId`, `DeltaAmount`, `ResultingStanding`, `SourceKind`, `SourceId`, `RecordedAt` | + +Standing snapshot is keyed **`(playerId, factionId)` → int`**; no separate row type beyond normalized ids + value. + +### 2. `IFactionStandingStore` + +Inject **`IFactionDefinitionRegistry`** in implementations (not on interface methods). + +- **`bool CanWritePlayer(string playerId)`** — dev bucket or Postgres player row exists. +- **`FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)`** — missing row ⇒ **0**; unknown faction ⇒ deny with `unknown_faction`; returned value clamped to faction min/max from registry. +- **`FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)`** — validates faction + writable player; reads current (default 0), adds delta, **clamps** to min/max, persists; first write returns success with before/after. Denies unknown faction / non-writable player / empty ids. **Does not append audit** (NEO-136 orchestrates both stores). + +XML remarks: consumed by **`ReputationOperations`** (NEO-136) and gate eval (NEO-137); not HTTP directly. + +### 3. `IReputationDeltaStore` + +- **`bool TryAppend(ReputationDeltaRow row)`** — append-only; returns `false` on duplicate `Id` or invalid row (empty player/faction/id). +- **`IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null)`** — test/audit read; ordered by `RecordedAt` then `Id` ascending. + +No update/delete APIs in prototype. + +### 4. In-memory implementations + +Mirror NEO-116 / NEO-38 patterns: + +- **`InMemoryFactionStandingStore`** — `ConcurrentDictionary` keyed by composite key; seeds dev player from `GamePositionOptions`; per-key locks. +- **`InMemoryReputationDeltaStore`** — append-only list/dictionary by row id; secondary index by player for queries; thread-safe. + +### 5. Postgres implementations + +Mirror NEO-116 quest store: + +- **`PostgresPlayerFactionStandingBootstrap`** + **`V009__player_faction_standing.sql`** + - Table `player_faction_standing (player_id FK → player_position, faction_id, standing INT, updated_at)` + - PK `(player_id, faction_id)` +- **`PostgresReputationDeltaBootstrap`** + **`V010__reputation_delta_audit.sql`** + - Table `reputation_delta_audit (id UUID/TEXT PK, player_id FK, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)` + - Index on `(player_id, recorded_at)` for audit queries +- **`PostgresFactionStandingStore`**, **`PostgresReputationDeltaStore`** — `EnsureSchema` on first use; standing store checks `player_position` exists before write. + +### 6. DI and `Program.cs` + +- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores(configuration)`** — Postgres pair when connection string set; else in-memory pair. +- Register **after** `AddFactionDefinitionCatalog` (stores depend on registry at runtime, not catalog singleton). +- Place near other player persistence (`AddPlayerQuestStateStore`, `AddRewardDeliveryStore`). +- **`InMemoryWebApplicationFactory`**: ensure stores resolve (override only if host tests need isolation — follow existing factory pattern). + +### 7. Tests + +**In-memory (`InMemoryFactionStandingStoreTests`):** + +- Missing row reads **0** for known faction. +- Read clamp when stored value exceeds max (direct internal seed or apply overflow scenario). +- First `TryApplyStandingDelta` +15 from 0 → 15. +- Apply delta that would exceed max clamps to **100**; below min clamps to **-100**. +- Unknown faction id → `unknown_faction` deny, standing unchanged. +- Non-writable player → deny. + +**In-memory (`InMemoryReputationDeltaStoreTests`):** + +- `TryAppend` success; `GetDeltasForPlayer` returns row. +- Second append with new id succeeds; duplicate id returns `false`. +- Empty player/faction/id → append deny. + +**Postgres (`FactionStandingPersistenceIntegrationTests`, `ReputationDeltaPersistenceIntegrationTests`):** + +- Write on factory A, read on factory B (`RequirePostgresFact` + harness). + +**Host DI smoke:** resolve both stores from `WebApplicationFactory` (optional one-liner in existing host test file). + +Manual QA: **none** (server infrastructure). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs` | Player/faction id normalization | +| `server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs` | Stable deny codes | +| `server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs` | Read result record | +| `server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs` | Mutation result record | +| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | Source kind constants | +| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs` | Audit row type | +| `server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs` | Standing store contract | +| `server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs` | Audit store contract | +| `server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs` | Dev in-memory standing | +| `server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs` | Dev in-memory audit | +| `server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs` | Migration bootstrap | +| `server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs` | Postgres standing | +| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs` | Audit migration bootstrap | +| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs` | Postgres audit | +| `server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs` | DI registration | +| `server/db/migrations/V009__player_faction_standing.sql` | Standing table | +| `server/db/migrations/V010__reputation_delta_audit.sql` | Audit table | +| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs` | Standing AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs` | Audit AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs` | Postgres standing | +| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs` | Postgres audit | + +## Files to modify + +| Path | Change | +|------|--------| +| `server/NeonSprawl.Server/Program.cs` | Register `AddFactionStandingStores` after faction catalog | +| `server/README.md` | Faction standing + reputation delta store sections | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Register/override faction stores if host tests require it | + +## Tests + +| Layer | What | +|-------|------| +| `InMemoryFactionStandingStoreTests` | Default 0, read clamp, apply delta, clamp min/max, unknown faction, player gate | +| `InMemoryReputationDeltaStoreTests` | Append, query, duplicate-id deny, validation | +| `FactionStandingPersistenceIntegrationTests` | Postgres write/read round-trip (`RequirePostgresFact`) | +| `ReputationDeltaPersistenceIntegrationTests` | Postgres append/query round-trip (`RequirePostgresFact`) | +| CI | Existing `dotnet test` + no content pipeline changes | + +## Implementation order + +1. Types, reason codes, ids, store interfaces. +2. In-memory standing + audit stores. +3. Postgres migrations + bootstrap + Postgres stores. +4. DI extension + `Program.cs`. +5. Unit tests → Postgres integration tests → `dotnet test`. +6. `server/README.md`. + +## Open questions / risks + +| Item | Agent recommendation | Status | +|------|---------------------|--------| +| Single vs dual migration files | **Two files (V009 standing, V010 audit)** — matches one-table-per-migration precedent (V007 gig, V008 quest) | `adopted` | +| Audit row id format | **UUID string** generated by caller (NEO-136); store rejects empty/duplicate | `adopted` | +| Standing store exposes `TryApplyStandingDelta` vs absolute set | **Delta primitive only** — matches backlog test wording and NEO-136 apply flow | `adopted` | +| Registry on read for unknown faction | **Deny unknown on read and write** — AC “store boundary”; gate eval (NEO-137) always uses catalog factions | `adopted` | + +## Client counterpart + +None for E7M3-03. Client faction HUD: **NEO-142** / capstone **NEO-143**. + +## Blocks + +**NEO-136** (`ReputationOperations`) and **NEO-137** (gate eval) depend on these stores landing first. diff --git a/docs/reviews/2026-06-15-NEO-135.md b/docs/reviews/2026-06-15-NEO-135.md new file mode 100644 index 0000000..4863056 --- /dev/null +++ b/docs/reviews/2026-06-15-NEO-135.md @@ -0,0 +1,56 @@ +# Code review — NEO-135 (E7M3-03) + +**Date:** 2026-06-15 +**Scope:** Branch `NEO-135-e7m3-faction-standing-reputation-stores` — commits `08ebfce` … `4fca45b` vs `main` +**Base:** `main` + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands **E7M3-03**: durable per-player **`IFactionStandingStore`** snapshots and append-only **`IReputationDeltaStore`** audit rows, with in-memory fallbacks and Postgres implementations (`V009`, `V010`) when `ConnectionStrings:NeonSprawl` is set. Stores inject **`IFactionDefinitionRegistry`** for fail-closed faction validation, default missing standing to **0** with read/write clamping, and gate mutations (and reads) on the dev player bucket / `player_position` row — mirroring NEO-116 quest-store patterns. Test coverage is solid: in-memory AAA for apply/clamp/deny paths, audit ordering/limit/duplicate-id semantics, Postgres persistence round-trips, and host DI smoke. **`766`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or player-facing behavior. Minor gaps: decomposition tracking docs not updated for E7M3-03, and one plan-listed in-memory read-clamp case is only covered via Postgres integration. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-135-implementation-plan.md` | **Matches** — shipped types, stores, migrations, DI, tests, and README reconcile with plan; AC checklist marked complete. Plan types table lists `invalid_delta` reason code not present in code (reserved for NEO-136 — see nits). | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-03 scope (dual stores, clamp, audit append, no public apply API, no client counterpart) aligns with implementation. Backlog AC checkboxes still unchecked (plan doc is authoritative for this branch). | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-03 stores landed (NEO-135). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-03 / NEO-135. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. | +| `server/README.md` | **Matches** — new faction standing + reputation delta store sections; correctly defers auditable apply to NEO-136. | +| `bruno/neon-sprawl-server/faction-standing/` | **N/A plan item** — health smoke folder added (reasonable host-start verification; not listed in plan). | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-03 faction standing + reputation delta stores** landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)). Same pattern as NEO-134 review follow-up.~~ **Done.** Module status + alignment table updated for E7M3-03 / NEO-135. + +2. ~~**In-memory read clamp test** — Plan §7 lists “Read clamp when stored value exceeds max” under `InMemoryFactionStandingStoreTests`. `TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax` exists only in Postgres integration tests. Add an in-memory case (e.g. apply to max then manually seed out-of-band via a second apply + direct dictionary seed is not possible — use apply overflow then verify read clamp, or test via Postgres-only path is insufficient for unit layer). Simplest: after clamp-to-max apply, verify read returns **100**; for true out-of-band raw, mirror Postgres test with reflection/internal test hook **or** document that read-clamp defense is integration-tested only. Prefer a dedicated in-memory test that applies deltas to max then asserts read clamp on subsequent reads at boundary (partial coverage) plus keeping Postgres test for stale DB values.~~ **Done.** Added `TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax` using internal `SeedRawStandingForTests`; Postgres integration test retained for stale DB values. + +3. ~~**Plan types table drift** — `NEO-135-implementation-plan.md` §1 lists `FactionStandingReasonCodes.invalid_delta`; shipped code only has `unknown_faction` and `player_not_writable`. Add a one-line note in the plan that `invalid_delta` is deferred to NEO-136 `ReputationOperations`, or add the constant now without wiring (avoid dead code — note-only is fine).~~ **Done.** Plan types table notes `invalid_delta` deferred to NEO-136. + +## Nits + +- ~~Nit: `PostgresReputationDeltaStore.TryAppend` relies on FK to `player_position`; a row for a non-existent player throws rather than returning `false`. Acceptable if NEO-136 always applies standing first; worth a one-line remark in README or interface XML for orchestrators.~~ **Done.** Remark added to `IReputationDeltaStore.TryAppend` XML and `server/README.md`. +- Nit: Host DI smoke (`Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds`) lives in `InMemoryFactionStandingStoreTests.cs` rather than a host-focused test file — fine per plan “optional one-liner.” +- ~~Nit: `FactionStandingPersistenceIntegrationTests` places `Assert.True(outcome.Success)` inside the Act block for the first factory write — minor AAA deviation; assertions could move entirely to Assert phase.~~ **Done.** +- ~~Nit: Empty `factionId` on read/write returns `player_not_writable` rather than `unknown_faction` — consistent with empty-id handling elsewhere, but slightly imprecise reason code.~~ **Done.** Empty faction id now returns `unknown_faction`. + +## Verification + +Commands run during review (passed): + +```bash +dotnet test server/NeonSprawl.Server.Tests +``` + +Author should confirm PR CI green before merge. With Postgres configured locally, `RequirePostgresFact` integration tests should also pass (`ConnectionStrings__NeonSprawl`). Bruno smoke (`bruno/neon-sprawl-server/faction-standing/`) is appropriate — no manual QA doc required per plan (infrastructure-only). + +**Intentional follow-on (not blockers):** `ReputationOperations` orchestration (NEO-136), gate eval reads (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot HUD (NEO-142+). diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs new file mode 100644 index 0000000..b276cba --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +[Collection("Postgres integration")] +public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + private const string PlayerId = "dev-local-1"; + private const string GridFactionId = "prototype_faction_grid_operators"; + + [RequirePostgresFact] + public async Task ApplyDeltaThenGetAcrossNewFactory_ShouldPersistStanding() + { + // Arrange + await ResetFactionStandingTableAsync(); + + // Act — write through first host + FactionStandingMutationOutcome writeOutcome; + using (var firstScope = Factory.Services.CreateScope()) + { + var store = firstScope.ServiceProvider.GetRequiredService(); + writeOutcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15); + } + + FactionStandingReadOutcome readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var secondScope = secondFactory.Services.CreateScope(); + var store = secondScope.ServiceProvider.GetRequiredService(); + readBack = store.TryGetStanding(PlayerId, GridFactionId); + } + + // Assert + Assert.True(writeOutcome.Success); + Assert.Equal(15, writeOutcome.NewStanding); + Assert.True(readBack.Success); + Assert.Equal(15, readBack.Standing); + } + + [RequirePostgresFact] + public async Task TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax() + { + // Arrange + await ResetFactionStandingTableAsync(); + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl") + ?? throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + await using (var conn = new NpgsqlConnection(cs)) + { + await conn.OpenAsync(); + await using var insert = new NpgsqlCommand( + """ + INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at) + VALUES (@pid, @fid, @standing, now()); + """, + conn); + insert.Parameters.AddWithValue("pid", PlayerId); + insert.Parameters.AddWithValue("fid", GridFactionId); + insert.Parameters.AddWithValue("standing", 999); + await insert.ExecuteNonQueryAsync(); + } + + // Act + FactionStandingReadOutcome readBack; + using (var scope = Factory.Services.CreateScope()) + { + var store = scope.ServiceProvider.GetRequiredService(); + readBack = store.TryGetStanding(PlayerId, GridFactionId); + } + + // Assert + Assert.True(readBack.Success); + Assert.Equal(100, readBack.Standing); + } + + private async Task ResetFactionStandingTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; + + var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + var standingDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V009__player_faction_standing.sql"); + if (!File.Exists(positionDdlPath) || !File.Exists(standingDdlPath)) + { + throw new FileNotFoundException("Test DDL for faction standing persistence not found."); + } + + var positionDdl = await File.ReadAllTextAsync(positionDdlPath); + var standingDdl = await File.ReadAllTextAsync(standingDdlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using (var applyPosition = new NpgsqlCommand(positionDdl, conn)) + { + await applyPosition.ExecuteNonQueryAsync(); + } + + await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn)) + { + await truncate.ExecuteNonQueryAsync(); + } + + PostgresPositionBootstrap.SeedDevPlayer(conn, options); + + await using (var applyStanding = new NpgsqlCommand(standingDdl, conn)) + { + await applyStanding.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs new file mode 100644 index 0000000..1afc3ca --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs @@ -0,0 +1,223 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public sealed class InMemoryFactionStandingStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string UnknownPlayerId = "unknown-player-xyz"; + private const string GridFactionId = "prototype_faction_grid_operators"; + private const string RustFactionId = "prototype_faction_rust_collective"; + + [Fact] + public void TryGetStanding_ShouldReturnZero_WhenRowMissing() + { + // Arrange + var store = CreateStore(); + // Act + var outcome = store.TryGetStanding(PlayerId, GridFactionId); + // Assert + Assert.True(outcome.Success); + Assert.Null(outcome.ReasonCode); + Assert.Equal(0, outcome.Standing); + } + + [Fact] + public void TryApplyStandingDelta_ShouldReturnFifteen_WhenFirstApplyFromZero() + { + // Arrange + var store = CreateStore(); + // Act + var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15); + // Assert + Assert.True(outcome.Success); + Assert.Equal(0, outcome.PreviousStanding); + Assert.Equal(15, outcome.NewStanding); + var readBack = store.TryGetStanding(PlayerId, GridFactionId); + Assert.True(readBack.Success); + Assert.Equal(15, readBack.Standing); + } + + [Fact] + public void TryApplyStandingDelta_ShouldClampToMax_WhenDeltaExceedsBand() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success); + // Act + var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 50); + // Assert + Assert.True(outcome.Success); + Assert.Equal(95, outcome.PreviousStanding); + Assert.Equal(100, outcome.NewStanding); + Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryApplyStandingDelta_ShouldClampToMin_WhenDeltaBelowBand() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success); + // Act + var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -50); + // Assert + Assert.True(outcome.Success); + Assert.Equal(-95, outcome.PreviousStanding); + Assert.Equal(-100, outcome.NewStanding); + Assert.Equal(-100, store.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax() + { + // Arrange + var store = CreateStore(); + store.SeedRawStandingForTests(PlayerId, GridFactionId, 999); + // Act + var outcome = store.TryGetStanding(PlayerId, GridFactionId); + // Assert + Assert.True(outcome.Success); + Assert.Null(outcome.ReasonCode); + Assert.Equal(100, outcome.Standing); + } + + [Fact] + public void TryGetStanding_ShouldReturnUnknownFaction_WhenFactionIdEmpty() + { + // Arrange + var store = CreateStore(); + // Act + var read = store.TryGetStanding(PlayerId, " "); + var write = store.TryApplyStandingDelta(PlayerId, "", 5); + // Assert + Assert.False(read.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode); + Assert.False(write.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode); + } + + [Fact] + public void TryGetStanding_ShouldDenyUnknownFaction_WithStructuredReason() + { + // Arrange + var store = CreateStore(); + // Act + var read = store.TryGetStanding(PlayerId, "not_a_real_faction"); + var write = store.TryApplyStandingDelta(PlayerId, "not_a_real_faction", 5); + // Assert + Assert.False(read.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode); + Assert.Equal(0, read.Standing); + Assert.False(write.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode); + Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryApplyStandingDelta_ShouldApplyToClampedEffectiveStanding_WhenRawStoredValueExceedsMax() + { + // Arrange + var store = CreateStore(); + store.SeedRawStandingForTests(PlayerId, GridFactionId, 999); + Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing); + // Act + var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -15); + // Assert + Assert.True(outcome.Success); + Assert.Equal(100, outcome.PreviousStanding); + Assert.Equal(85, outcome.NewStanding); + Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown() + { + // Arrange + var store = CreateStore(); + // Act + var read = store.TryGetStanding(UnknownPlayerId, "not_a_real_faction"); + var write = store.TryApplyStandingDelta(UnknownPlayerId, "not_a_real_faction", 5); + // Assert + Assert.False(read.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode); + Assert.False(write.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode); + } + + [Fact] + public void TryApplyStandingDelta_ShouldDenyNonWritablePlayer() + { + // Arrange + var store = CreateStore(); + // Act + var outcome = store.TryApplyStandingDelta(UnknownPlayerId, GridFactionId, 10); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode); + } + + [Fact] + public void TryGetStanding_ShouldDenyNonWritablePlayer() + { + // Arrange + var store = CreateStore(); + // Act + var outcome = store.TryGetStanding(UnknownPlayerId, GridFactionId); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode); + } + + [Fact] + public void TryApplyStandingDelta_ShouldTrackSeparateFactionsPerPlayer() + { + // Arrange + var store = CreateStore(); + // Act + Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 15).Success); + Assert.True(store.TryApplyStandingDelta(PlayerId, RustFactionId, 10).Success); + // Assert + Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Equal(10, store.TryGetStanding(PlayerId, RustFactionId).Standing); + } + + [Fact] + public async Task Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var standingStore = factory.Services.GetRequiredService(); + var auditStore = factory.Services.GetRequiredService(); + var read = standingStore.TryGetStanding(PlayerId, GridFactionId); + // Assert + Assert.NotNull(auditStore); + Assert.True(read.Success); + Assert.Equal(0, read.Standing); + } + + private static InMemoryFactionStandingStore CreateStore() + { + var registry = CreatePrototypeRegistry(); + var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); + return new InMemoryFactionStandingStore(options, registry); + } + + private static FactionDefinitionRegistry CreatePrototypeRegistry() + { + var byId = new Dictionary(StringComparer.Ordinal) + { + [GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId], + [RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId], + }; + var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new FactionDefinitionRegistry(catalog); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs new file mode 100644 index 0000000..0ba538c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs @@ -0,0 +1,117 @@ +using NeonSprawl.Server.Game.Factions; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public sealed class InMemoryReputationDeltaStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string GridFactionId = "prototype_faction_grid_operators"; + private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryAppend_ShouldReturnTrueAndQueryRow_WhenFirstAppend() + { + // Arrange + var store = CreateStore(); + var row = CreateRow("delta-001", 15, 15); + // Act + var appended = store.TryAppend(row); + var rows = store.GetDeltasForPlayer(PlayerId); + // Assert + Assert.True(appended); + var read = Assert.Single(rows); + Assert.Equal(row.Id, read.Id); + Assert.Equal(15, read.DeltaAmount); + Assert.Equal(15, read.ResultingStanding); + } + + [Fact] + public void TryAppend_ShouldReturnFalse_WhenDuplicateId() + { + // Arrange + var store = CreateStore(); + var row = CreateRow("delta-dup", 10, 10); + Assert.True(store.TryAppend(row)); + // Act + var duplicate = store.TryAppend(row with { DeltaAmount = 99, ResultingStanding = 99 }); + // Assert + Assert.False(duplicate); + var read = Assert.Single(store.GetDeltasForPlayer(PlayerId)); + Assert.Equal(10, read.DeltaAmount); + } + + [Fact] + public void TryAppend_ShouldReturnTrueTwice_WhenDistinctIds() + { + // Arrange + var store = CreateStore(); + // Act + var first = store.TryAppend(CreateRow("delta-a", 15, 15)); + var second = store.TryAppend(CreateRow("delta-b", 10, 25)); + // Assert + Assert.True(first); + Assert.True(second); + Assert.Equal(2, store.GetDeltasForPlayer(PlayerId).Count); + } + + [Fact] + public void TryAppend_ShouldReturnFalse_WhenRowInvalid() + { + // Arrange + var store = CreateStore(); + var emptyId = CreateRow("", 5, 5); + var emptyPlayer = CreateRow("delta-x", 5, 5) with { PlayerId = " " }; + var emptyFaction = CreateRow("delta-y", 5, 5) with { FactionId = "" }; + // Act + var noId = store.TryAppend(emptyId); + var noPlayer = store.TryAppend(emptyPlayer); + var noFaction = store.TryAppend(emptyFaction); + // Assert + Assert.False(noId); + Assert.False(noPlayer); + Assert.False(noFaction); + Assert.Empty(store.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void GetDeltasForPlayer_ShouldOrderByRecordedAtThenId() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryAppend(CreateRow("delta-z", 1, 1) with { RecordedAt = RecordedAt.AddMinutes(2) })); + Assert.True(store.TryAppend(CreateRow("delta-a", 2, 3) with { RecordedAt = RecordedAt })); + Assert.True(store.TryAppend(CreateRow("delta-m", 3, 6) with { RecordedAt = RecordedAt })); + // Act + var rows = store.GetDeltasForPlayer(PlayerId); + // Assert + Assert.Equal(["delta-a", "delta-m", "delta-z"], rows.Select(static r => r.Id).ToArray()); + } + + [Fact] + public void GetDeltasForPlayer_ShouldRespectLimit_WhenProvided() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryAppend(CreateRow("delta-1", 1, 1))); + Assert.True(store.TryAppend(CreateRow("delta-2", 1, 2))); + // Act + var rows = store.GetDeltasForPlayer(PlayerId, limit: 1); + // Assert + Assert.Single(rows); + Assert.Equal("delta-1", rows[0].Id); + } + + private static InMemoryReputationDeltaStore CreateStore() => new(); + + private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) => + new( + id, + PlayerId, + GridFactionId, + delta, + resulting, + ReputationDeltaSourceKinds.QuestCompletion, + "prototype_quest_operator_chain", + RecordedAt); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs new file mode 100644 index 0000000..50e512f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +[Collection("Postgres integration")] +public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + private const string PlayerId = "dev-local-1"; + private const string GridFactionId = "prototype_faction_grid_operators"; + private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero); + + [RequirePostgresFact] + public async Task AppendThenGetAcrossNewFactory_ShouldPersistAuditRow() + { + // Arrange + await ResetReputationDeltaTableAsync(); + var row = new ReputationDeltaRow( + "delta-persist-001", + PlayerId, + GridFactionId, + 15, + 15, + ReputationDeltaSourceKinds.QuestCompletion, + "prototype_quest_operator_chain", + RecordedAt); + + // Act — write through first host + using (var firstScope = Factory.Services.CreateScope()) + { + var store = firstScope.ServiceProvider.GetRequiredService(); + Assert.True(store.TryAppend(row)); + } + + IReadOnlyList readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var secondScope = secondFactory.Services.CreateScope(); + var store = secondScope.ServiceProvider.GetRequiredService(); + readBack = store.GetDeltasForPlayer(PlayerId); + } + + // Assert + var persisted = Assert.Single(readBack); + Assert.Equal(row.Id, persisted.Id); + Assert.Equal(15, persisted.DeltaAmount); + Assert.Equal(15, persisted.ResultingStanding); + Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, persisted.SourceKind); + } + + private async Task ResetReputationDeltaTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; + + var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + var auditDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V010__reputation_delta_audit.sql"); + if (!File.Exists(positionDdlPath) || !File.Exists(auditDdlPath)) + { + throw new FileNotFoundException("Test DDL for reputation delta persistence not found."); + } + + var positionDdl = await File.ReadAllTextAsync(positionDdlPath); + var auditDdl = await File.ReadAllTextAsync(auditDdlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using (var applyPosition = new NpgsqlCommand(positionDdl, conn)) + { + await applyPosition.ExecuteNonQueryAsync(); + } + + await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn)) + { + await truncate.ExecuteNonQueryAsync(); + } + + PostgresPositionBootstrap.SeedDevPlayer(conn, options); + + await using (var applyAudit = new NpgsqlCommand(auditDdl, conn)) + { + await applyAudit.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 2d2043e..48925a7 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -89,6 +89,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs new file mode 100644 index 0000000..f721da8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs @@ -0,0 +1,41 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Id normalization for faction standing stores (NEO-135). +public static class FactionStandingIds +{ + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizePlayerId(string? playerId) + { + var trimmed = playerId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } + + /// Trim; empty when input is null/whitespace. Faction ids stay case-sensitive (catalog ids are lowercase snake). + public static string NormalizeFactionId(string? factionId) + { + var trimmed = factionId?.Trim(); + return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed; + } + + /// Composite store key for + . + public static string MakeStandingKey(string? playerId, string? factionId) + { + var player = NormalizePlayerId(playerId); + var faction = NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return string.Empty; + } + + return $"{player}\0{faction}"; + } + + /// Clamps to the faction definition band. + public static int ClampStanding(int standing, FactionDefRow definition) => + Math.Clamp(standing, definition.MinStanding, definition.MaxStanding); +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs new file mode 100644 index 0000000..5281378 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-135). +public readonly record struct FactionStandingMutationOutcome( + bool Success, + string? ReasonCode, + int PreviousStanding, + int NewStanding); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs new file mode 100644 index 0000000..78bb64d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-135). +public readonly record struct FactionStandingReadOutcome( + bool Success, + string? ReasonCode, + int Standing); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs new file mode 100644 index 0000000..d843526 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable deny reason codes for faction standing store operations (NEO-135). +public static class FactionStandingReasonCodes +{ + public const string UnknownFaction = "unknown_faction"; + + public const string PlayerNotWritable = "player_not_writable"; +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd38d01 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Factions; + +/// Registers faction standing + reputation delta audit persistence (NEO-135). +public static class FactionStandingServiceCollectionExtensions +{ + /// PostgreSQL when ConnectionStrings:NeonSprawl is set; otherwise in-memory fallback. + public static IServiceCollection AddFactionStandingStores(this IServiceCollection services, IConfiguration configuration) + { + var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs new file mode 100644 index 0000000..2d5086c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Persisted per-player faction standing keyed by (playerId, factionId) (NEO-135). +/// Missing row ⇒ neutral 0 at read. Consumed by ReputationOperations (NEO-136) and gate eval (NEO-137). +/// +public interface IFactionStandingStore +{ + /// Whether mutations for are allowed (dev bucket or Postgres player_position row). + bool CanWritePlayer(string playerId); + + /// + /// Reads standing for one faction. Missing row ⇒ 0 clamped to faction min/max. + /// Unknown faction id ⇒ structured deny with . + /// + FactionStandingReadOutcome TryGetStanding(string playerId, string factionId); + + /// + /// Applies a signed delta, clamps resulting standing to faction min/max, and persists. + /// Does not append audit rows — is orchestrated by NEO-136. + /// + FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount); +} diff --git a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs new file mode 100644 index 0000000..13ae723 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Append-only reputation delta audit log (NEO-135). +public interface IReputationDeltaStore +{ + /// + /// First append with a given row Id returns true; duplicate ids return false. + /// Postgres: row references player_position — orchestrators (NEO-136) should apply standing before audit append. + /// + bool TryAppend(ReputationDeltaRow row); + + /// Audit rows for one player ordered by then Id ascending. + IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null); +} diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs new file mode 100644 index 0000000..4f814bd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs @@ -0,0 +1,124 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Factions; + +/// Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135). +public sealed class InMemoryFactionStandingStore( + IOptions options, + IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore +{ + private readonly HashSet knownPlayers = CreateKnownPlayers(options.Value); + + private readonly ConcurrentDictionary standingByKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + private static HashSet CreateKnownPlayers(GamePositionOptions o) + { + var id = FactionStandingIds.NormalizePlayerId(o.DevPlayerId); + if (id.Length == 0) + { + throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); + } + + return new HashSet(StringComparer.OrdinalIgnoreCase) { id }; + } + + /// + public bool CanWritePlayer(string playerId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + return player.Length > 0 && knownPlayers.Contains(player); + } + + /// + public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (faction.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.UnknownFaction); + } + + if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) + { + return DenyRead(FactionStandingReasonCodes.UnknownFaction); + } + + if (!CanWritePlayer(player)) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + var key = FactionStandingIds.MakeStandingKey(player, faction); + var raw = standingByKey.GetValueOrDefault(key, 0); + return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition)); + } + + /// + public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (faction.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.UnknownFaction); + } + + if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) + { + return DenyMutation(FactionStandingReasonCodes.UnknownFaction); + } + + if (!CanWritePlayer(player)) + { + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + var key = FactionStandingIds.MakeStandingKey(player, faction); + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + var raw = standingByKey.GetValueOrDefault(key, 0); + var previousStanding = FactionStandingIds.ClampStanding(raw, definition); + var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition); + standingByKey[key] = newStanding; + return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); + } + } + + private static FactionStandingReadOutcome DenyRead(string reasonCode) => + new(false, reasonCode, 0); + + private static FactionStandingMutationOutcome DenyMutation(string reasonCode) => + new(false, reasonCode, 0, 0); + + /// Test-only seed of raw standing before read-clamp (NEO-135 unit tests). + internal void SeedRawStandingForTests(string playerId, string factionId, int rawStanding) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0 || !CanWritePlayer(player)) + { + return; + } + + var key = FactionStandingIds.MakeStandingKey(player, faction); + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + standingByKey[key] = rawStanding; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs new file mode 100644 index 0000000..fcfff4f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Factions; + +/// Thread-safe in-memory append-only reputation delta audit log (NEO-135). +public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore +{ + private readonly ConcurrentDictionary rowsById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public bool TryAppend(ReputationDeltaRow row) + { + if (!IsValidRow(row)) + { + return false; + } + + var normalized = row with + { + Id = row.Id.Trim(), + PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId), + FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId), + SourceKind = row.SourceKind.Trim(), + SourceId = row.SourceId.Trim(), + }; + + lock (idLocks.GetOrAdd(normalized.Id, _ => new object())) + { + if (rowsById.ContainsKey(normalized.Id)) + { + return false; + } + + rowsById[normalized.Id] = normalized; + return true; + } + } + + /// + public IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + if (player.Length == 0) + { + return []; + } + + IEnumerable query = rowsById.Values + .Where(r => string.Equals(r.PlayerId, player, StringComparison.Ordinal)) + .OrderBy(static r => r.RecordedAt) + .ThenBy(static r => r.Id, StringComparer.Ordinal); + + if (limit is > 0) + { + query = query.Take(limit.Value); + } + + return query.ToArray(); + } + + private static bool IsValidRow(ReputationDeltaRow row) => + row.Id.Trim().Length > 0 && + FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && + FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 && + row.SourceKind.Trim().Length > 0 && + row.SourceId.Trim().Length > 0; +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs new file mode 100644 index 0000000..fdb3ffd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs @@ -0,0 +1,154 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135). +public sealed class PostgresFactionStandingStore( + Npgsql.NpgsqlDataSource dataSource, + IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore +{ + /// + public bool CanWritePlayer(string playerId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + if (player.Length == 0) + { + return false; + } + + PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + return PlayerExists(conn, player); + } + + /// + public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (faction.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.UnknownFaction); + } + + if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) + { + return DenyRead(FactionStandingReasonCodes.UnknownFaction); + } + + PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + if (!PlayerExists(conn, player)) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT standing + FROM player_faction_standing + WHERE player_id = @pid AND faction_id = @fid + LIMIT 1; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("fid", faction); + var scalar = cmd.ExecuteScalar(); + var raw = scalar is null or DBNull + ? 0 + : scalar is int xi + ? xi + : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); + return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition)); + } + + /// + public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (faction.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.UnknownFaction); + } + + if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) + { + return DenyMutation(FactionStandingReasonCodes.UnknownFaction); + } + + PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, player, tx)) + { + tx.Rollback(); + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + var raw = 0; + using (var sel = new Npgsql.NpgsqlCommand( + """ + SELECT standing + FROM player_faction_standing + WHERE player_id = @pid AND faction_id = @fid + LIMIT 1 + FOR UPDATE; + """, + conn, + tx)) + { + sel.Parameters.AddWithValue("pid", player); + sel.Parameters.AddWithValue("fid", faction); + var scalar = sel.ExecuteScalar(); + raw = scalar is null or DBNull + ? 0 + : scalar is int xi + ? xi + : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); + } + + var previousStanding = FactionStandingIds.ClampStanding(raw, definition); + var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition); + + using var upsert = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at) + VALUES (@pid, @fid, @standing, now()) + ON CONFLICT (player_id, faction_id) + DO UPDATE SET standing = EXCLUDED.standing, updated_at = now(); + """, + conn, + tx); + upsert.Parameters.AddWithValue("pid", player); + upsert.Parameters.AddWithValue("fid", faction); + upsert.Parameters.AddWithValue("standing", newStanding); + upsert.ExecuteNonQuery(); + tx.Commit(); + return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); + } + + private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null) + { + using var cmd = new Npgsql.NpgsqlCommand( + "SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;", + conn, + tx); + cmd.Parameters.AddWithValue("pid", playerIdNormalized); + return cmd.ExecuteScalar() is not null; + } + + private static FactionStandingReadOutcome DenyRead(string reasonCode) => + new(false, reasonCode, 0); + + private static FactionStandingMutationOutcome DenyMutation(string reasonCode) => + new(false, reasonCode, 0, 0); +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs b/server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs new file mode 100644 index 0000000..72414ce --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Applies NEO-135 faction standing table DDL once per process. +public static class PostgresPlayerFactionStandingBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V009__player_faction_standing.sql"); + + private static readonly object SchemaGate = new(); + + private static int _schemaReady; + + public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource) + { + if (Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + lock (SchemaGate) + { + if (Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"NEO-135 faction standing DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs new file mode 100644 index 0000000..30439e7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Applies NEO-135 reputation delta audit table DDL once per process. +public static class PostgresReputationDeltaBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V010__reputation_delta_audit.sql"); + + private static readonly object SchemaGate = new(); + + private static int _schemaReady; + + public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource) + { + if (Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + lock (SchemaGate) + { + if (Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"NEO-135 reputation delta DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs new file mode 100644 index 0000000..14bdd8f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs @@ -0,0 +1,100 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// PostgreSQL-backed append-only reputation delta audit log (NEO-135). +public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore +{ + /// + public bool TryAppend(ReputationDeltaRow row) + { + if (!IsValidRow(row)) + { + return false; + } + + var normalized = row with + { + Id = row.Id.Trim(), + PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId), + FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId), + SourceKind = row.SourceKind.Trim(), + SourceId = row.SourceId.Trim(), + }; + + PostgresReputationDeltaBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + INSERT INTO reputation_delta_audit ( + id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at) + VALUES (@id, @pid, @fid, @delta, @result, @kind, @source_id, @recorded) + ON CONFLICT (id) DO NOTHING; + """, + conn); + cmd.Parameters.AddWithValue("id", normalized.Id); + cmd.Parameters.AddWithValue("pid", normalized.PlayerId); + cmd.Parameters.AddWithValue("fid", normalized.FactionId); + cmd.Parameters.AddWithValue("delta", normalized.DeltaAmount); + cmd.Parameters.AddWithValue("result", normalized.ResultingStanding); + cmd.Parameters.AddWithValue("kind", normalized.SourceKind); + cmd.Parameters.AddWithValue("source_id", normalized.SourceId); + cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt); + return cmd.ExecuteNonQuery() == 1; + } + + /// + public IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + if (player.Length == 0) + { + return []; + } + + PostgresReputationDeltaBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + var sql = limit is > 0 + ? """ + SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at + FROM reputation_delta_audit + WHERE player_id = @pid + ORDER BY recorded_at ASC, id ASC + LIMIT @lim; + """ + : """ + SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at + FROM reputation_delta_audit + WHERE player_id = @pid + ORDER BY recorded_at ASC, id ASC; + """; + using var cmd = new Npgsql.NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("pid", player); + if (limit is > 0) + { + cmd.Parameters.AddWithValue("lim", limit.Value); + } + + var rows = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(new ReputationDeltaRow( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetInt32(3), + reader.GetInt32(4), + reader.GetString(5), + reader.GetString(6), + reader.GetFieldValue(7))); + } + + return rows; + } + + private static bool IsValidRow(ReputationDeltaRow row) => + row.Id.Trim().Length > 0 && + FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && + FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 && + row.SourceKind.Trim().Length > 0 && + row.SourceId.Trim().Length > 0; +} diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs new file mode 100644 index 0000000..a6d42a2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Append-only reputation delta audit row (NEO-135). +public sealed record ReputationDeltaRow( + string Id, + string PlayerId, + string FactionId, + int DeltaAmount, + int ResultingStanding, + string SourceKind, + string SourceId, + DateTimeOffset RecordedAt); diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs new file mode 100644 index 0000000..873a50d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable source-kind values for (NEO-135). +public static class ReputationDeltaSourceKinds +{ + public const string QuestCompletion = "quest_completion"; +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 1c28fd3..f400be5 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddRewardDeliveryStore(); +builder.Services.AddFactionStandingStores(builder.Configuration); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddPlayerCombatHealthStore(); diff --git a/server/README.md b/server/README.md index e25af33..78f6b0e 100644 --- a/server/README.md +++ b/server/README.md @@ -63,6 +63,25 @@ On startup the host loads every **`*_factions.json`** under the factions directo On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code. +## Faction standing store (NEO-135) + +Per-player faction reputation snapshots live in **`IFactionStandingStore`**, keyed by **`(playerId, factionId)`**. A missing row reads as neutral **`0`**, clamped to the faction definition **`minStanding`…`maxStanding`**. Unknown faction ids return structured deny with reason code **`unknown_faction`** at the store boundary. Game code should call **`ReputationOperations`** (NEO-136) for auditable apply — not the standing store alone — so source attribution and audit rows stay consistent. + +**Standing store methods:** + +- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`). +- **`TryGetStanding`** — read one faction; missing row ⇒ **`0`** (clamped). +- **`TryApplyStandingDelta`** — internal primitive: add signed delta, clamp result, persist. Does **not** append audit rows. + +Append-only **`ReputationDelta`** audit rows live in **`IReputationDeltaStore`**: + +- **`TryAppend`** — first row id returns `true`; duplicate ids return `false`. +- **`GetDeltasForPlayer`** — ordered by **`recordedAt`** then **`id`** (prototype test/audit read). + +**Postgres orchestration:** audit rows FK to **`player_position`** — append after standing apply succeeds (NEO-136 **`ReputationOperations`**); a missing player row causes insert failure rather than a structured `false` from **`TryAppend`**. + +**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). + ## Resource-node catalog (`content/resource-nodes`, NEO-58) On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. diff --git a/server/db/migrations/V009__player_faction_standing.sql b/server/db/migrations/V009__player_faction_standing.sql new file mode 100644 index 0000000..1f09911 --- /dev/null +++ b/server/db/migrations/V009__player_faction_standing.sql @@ -0,0 +1,10 @@ +-- NEO-135: per-player faction standing snapshots. +CREATE TABLE IF NOT EXISTS player_faction_standing ( + player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE, + faction_id TEXT NOT NULL, + standing INTEGER NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (player_id, faction_id) +); + +COMMENT ON TABLE player_faction_standing IS 'Persisted faction standing per player (NEO-135); missing row means neutral 0 at read.'; diff --git a/server/db/migrations/V010__reputation_delta_audit.sql b/server/db/migrations/V010__reputation_delta_audit.sql new file mode 100644 index 0000000..cc8f06d --- /dev/null +++ b/server/db/migrations/V010__reputation_delta_audit.sql @@ -0,0 +1,17 @@ +-- NEO-135: append-only reputation delta audit log. +CREATE TABLE IF NOT EXISTS reputation_delta_audit ( + id TEXT NOT NULL PRIMARY KEY, + player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE, + faction_id TEXT NOT NULL, + delta_amount INTEGER NOT NULL, + resulting_standing INTEGER NOT NULL, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_reputation_delta_audit_player_recorded + ON reputation_delta_audit (player_id, recorded_at, id); + +COMMENT ON TABLE reputation_delta_audit IS 'Append-only faction reputation delta audit (NEO-135).';