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 index 0838a1c..7f7b45b 100644 --- a/docs/plans/NEO-135-implementation-plan.md +++ b/docs/plans/NEO-135-implementation-plan.md @@ -63,7 +63,7 @@ Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationD - **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 (`766` tests green). +- **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 @@ -73,7 +73,7 @@ Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationD | Type | Role | |------|------| | `FactionStandingIds` | Normalize `playerId` / `factionId`; composite key helper (mirror `QuestProgressIds`) | -| `FactionStandingReasonCodes` | `unknown_faction`, `player_not_writable`, `invalid_delta` | +| `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) | diff --git a/docs/reviews/2026-06-15-NEO-135.md b/docs/reviews/2026-06-15-NEO-135.md index 41e4d5a..4863056 100644 --- a/docs/reviews/2026-06-15-NEO-135.md +++ b/docs/reviews/2026-06-15-NEO-135.md @@ -18,8 +18,8 @@ This branch lands **E7M3-03**: durable per-player **`IFactionStandingStore`** sn |------|--------| | `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` | **Partially matches** — module purpose/contracts align; **Status** line still stops at E7M3-02 (NEO-134) and does not record E7M3-03 store landing. | -| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M3 row still lists **E7M3-03+** as future; should note stores landed after merge. | +| `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). | @@ -30,18 +30,18 @@ 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. +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. +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). +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. +- ~~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. -- 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. +- ~~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 diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs index 54bd174..b276cba 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs @@ -23,12 +23,11 @@ public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrati await ResetFactionStandingTableAsync(); // Act — write through first host + FactionStandingMutationOutcome writeOutcome; using (var firstScope = Factory.Services.CreateScope()) { var store = firstScope.ServiceProvider.GetRequiredService(); - var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15); - Assert.True(outcome.Success); - Assert.Equal(15, outcome.NewStanding); + writeOutcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15); } FactionStandingReadOutcome readBack; @@ -40,6 +39,8 @@ public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrati } // Assert + Assert.True(writeOutcome.Success); + Assert.Equal(15, writeOutcome.NewStanding); Assert.True(readBack.Success); Assert.Equal(15, readBack.Standing); } diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs index 6cc5cf7..b5dd1f3 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs @@ -72,6 +72,35 @@ public sealed class InMemoryFactionStandingStoreTests 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() { diff --git a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs index 89462f2..13ae723 100644 --- a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs @@ -3,7 +3,10 @@ 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. + /// + /// 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. diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs index 243cc21..3a5eb6c 100644 --- a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs @@ -38,11 +38,16 @@ public sealed class InMemoryFactionStandingStore( { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); - if (player.Length == 0 || faction.Length == 0) + if (player.Length == 0) { return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); } + if (faction.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.UnknownFaction); + } + if (!CanWritePlayer(player)) { return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); @@ -63,11 +68,16 @@ public sealed class InMemoryFactionStandingStore( { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); - if (player.Length == 0 || faction.Length == 0) + if (player.Length == 0) { return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); } + if (faction.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.UnknownFaction); + } + if (!CanWritePlayer(player)) { return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); @@ -94,4 +104,21 @@ public sealed class InMemoryFactionStandingStore( 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/PostgresFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs index 97cf1fd..3fe7203 100644 --- a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs @@ -24,11 +24,16 @@ public sealed class PostgresFactionStandingStore( { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); - if (player.Length == 0 || faction.Length == 0) + 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); @@ -65,11 +70,16 @@ public sealed class PostgresFactionStandingStore( { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); - if (player.Length == 0 || faction.Length == 0) + 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); diff --git a/server/README.md b/server/README.md index eb550e6..78f6b0e 100644 --- a/server/README.md +++ b/server/README.md @@ -78,6 +78,8 @@ 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)