diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index e078daf..0723166 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; **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**. | +| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-05** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **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 3a345a4..23311ec 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 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 | +| 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 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05+** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **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), [NEO-136 plan](../../plans/NEO-136-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-136-implementation-plan.md b/docs/plans/NEO-136-implementation-plan.md new file mode 100644 index 0000000..392bc7b --- /dev/null +++ b/docs/plans/NEO-136-implementation-plan.md @@ -0,0 +1,159 @@ +# NEO-136 — E7M3-04: ReputationOperations (apply ReputationDelta) + +**Linear:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) +**Branch:** `NEO-136-e7m3-reputation-operations-apply-delta` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-04** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-105](NEO-105-implementation-plan.md) / `EncounterCompletionOperations` — static ops + reason codes + compensating rollback; [NEO-127](NEO-127-implementation-plan.md) — orchestrate store writes in order +**Precursor:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` (**landed on `main`**) +**Blocks:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) (reward bundle rep wiring), [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites) + +## Goal + +Server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation + append-only audit so game code never calls **`IFactionStandingStore.TryApplyStandingDelta`** alone. Apply clamps to faction min/max, records one **`ReputationDelta`** row on success, and returns structured outcomes. + +## Kickoff clarifications + +**No clarifications needed.** Linear AC, [E7M3-04 backlog scope](E7M3-pre-production-backlog.md#e7m3-04--reputationoperations-apply-reputationdelta), NEO-135 handoff notes, and NEO-62/NEO-105/NEO-127 precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Orchestration shape | **Static `ReputationOperations`** with injected stores + `TimeProvider` | `CraftOperations`, `RewardRouterOperations`, `EncounterCompletionOperations` precedent | +| Faction validation | **Delegate to `IFactionStandingStore`** (already fail-closed via `IFactionDefinitionRegistry`) | Backlog “validates faction id via registry”; store implements that at boundary — no duplicate registry inject | +| `invalid_delta` | **Deny `deltaAmount == 0`** with reason code **`invalid_delta`** at ops layer before store | NEO-135 deferred constant to NEO-136; `SkillProgressionGrantOperations` denies `amount <= 0` | +| Audit row id | **Caller supplies non-empty `deltaId`** (UUID string) | NEO-135 adopted decision table: “UUID string generated by caller (NEO-136)” | +| Source attribution | **Require non-empty `sourceKind` + `sourceId`**; prototype constant `quest_completion` from `ReputationDeltaSourceKinds` | NEO-135 audit row shape; NEO-138 passes quest id as `sourceId` | +| Commit order | **Standing apply first, audit append second** | `IReputationDeltaStore` XML + `server/README.md` Postgres FK remark | +| Audit append failure | **Compensating standing rollback** — inverse delta via `TryApplyStandingDelta` when `TryAppend` returns `false` | NEO-62/NEO-69/NEO-105 compensating rollback precedent; avoid standing without audit | +| Result type | **New `ReputationApplyOutcome`** (success, reason, before/after standing, optional persisted audit row) | Store outcomes lack ops-level codes (`invalid_delta`, `audit_append_failed`) | +| HTTP / quest wiring / Godot | **Out of scope** | Backlog E7M3-04; NEO-138 wires `RewardRouterOperations` | +| Bruno / manual QA | **None** — server engine only | Backlog client counterpart: none | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`. +- **`ReputationApplyOutcome`**, **`ReputationApplyReasonCodes`** (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough `unknown_faction`, `player_not_writable` from standing store). +- Add **`invalid_delta`** to **`FactionStandingReasonCodes`** only if store layer needs it later — **prefer ops-only `ReputationApplyReasonCodes`** to keep store contract unchanged. +- Unit tests (AAA): happy path (+15 from 0, audit row queryable), clamp at min/max via ops, unknown faction deny, `deltaAmount == 0` deny, duplicate `deltaId` audit deny with standing unchanged (rollback), empty `deltaId` / source deny. +- Host DI smoke: resolve stores + one ops apply through factory services (extend existing faction store host test or dedicated ops test). +- `server/README.md` **`ReputationOperations`** section (callers must use ops, not standing store alone). + +**Out of scope:** + +- Quest bundle wiring via **`RewardRouterOperations`** (NEO-138 / E7M3-06). +- Quest accept gate evaluation (NEO-137). +- HTTP GET standing (NEO-139), telemetry hook comments (NEO-141), Godot (NEO-142+). +- Postgres-specific integration test for ops (store persistence already covered by NEO-135; ops tests use in-memory stores). + +**Client counterpart:** none (server engine). + +## Acceptance criteria checklist + +- [x] Delta apply updates standing once and records audit row. +- [x] Standing never exceeds faction min/max after apply. +- [x] `dotnet test` covers ops happy path, clamp, unknown faction deny, and invalid-delta deny. + +## Implementation reconciliation (shipped) + +- **Operations:** `ReputationOperations.TryApplyDelta` — pre-flight validation, standing apply, audit append, compensating rollback on append failure. +- **Types:** `ReputationApplyOutcome`, `ReputationApplyReasonCodes` (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`). +- **Tests:** `ReputationOperationsTests` — 11 AAA cases including host DI smoke and clamped audit-append rollback (`781` tests green). +- **Docs:** `server/README.md` ReputationOperations section; E7.M3 module + alignment register updated. + +## Technical approach + +### 1. Types (`Game/Factions/`) + +| Type | Role | +|------|------| +| `ReputationApplyReasonCodes` | `invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough store codes | +| `ReputationApplyOutcome` | `Success`, `ReasonCode`, `PreviousStanding`, `NewStanding`, optional `AuditRow` (`ReputationDeltaRow`) | + +### 2. `ReputationOperations.TryApplyDelta` + +Signature (static, mirror other ops): + +```csharp +public static ReputationApplyOutcome TryApplyDelta( + string playerId, + string factionId, + int deltaAmount, + string deltaId, + string sourceKind, + string sourceId, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, + TimeProvider timeProvider) +``` + +**Flow:** + +1. Normalize ids via **`FactionStandingIds`**; use normalized values for store calls and audit row. +2. **Pre-flight denies** (no store mutation): + - `deltaAmount == 0` → `invalid_delta` + - empty `deltaId` → `invalid_delta_id` + - empty `sourceKind` or `sourceId` → `invalid_source` +3. **`standingStore.TryApplyStandingDelta(playerId, factionId, deltaAmount)`** — on deny, return outcome with store **`ReasonCode`** and zeroed standings. +4. Build **`ReputationDeltaRow`** with caller `deltaId`, **applied** delta (`newStanding - previousStanding`), `NewStanding` from mutation outcome, normalized ids, **`timeProvider.GetUtcNow()`**. +5. **`auditStore.TryAppend(row)`** — on `true`, return success with audit row. +6. On append `false`: **compensating rollback** — `standingStore.TryApplyStandingDelta` with **`-appliedDelta`** where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding` (skip when zero); return deny with **`audit_append_failed`**. + +XML remarks: consumed by **`RewardRouterOperations`** (NEO-138) and future admin tools; not HTTP directly. NEO-141 telemetry hook site on successful apply (comment-only in NEO-141). + +### 3. Tests + +**`ReputationOperationsTests`** (in-memory stores + prototype faction registry helper mirroring `InMemoryFactionStandingStoreTests`): + +| Test | Covers | +|------|--------| +| `TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath` | +15 from 0; audit row fields; `GetDeltasForPlayer` | +| `TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand` | Standing 95 + 50 → 100; audit `ResultingStanding` 100 | +| `TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand` | Standing -95 + (-50) → -100 | +| `TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason` | No standing change; no audit rows | +| `TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero` | `invalid_delta` | +| `TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty` | `invalid_delta_id` | +| `TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty` | `invalid_source` | +| `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` | Pre-seeded duplicate id at standing 95; +50 clamped apply rolls back to 95 | +| `TryApplyDelta_ShouldDenyNonWritablePlayer` | `player_not_writable` passthrough | + +**Host DI smoke:** one test resolving stores from `InMemoryWebApplicationFactory` and applying via `ReputationOperations` (can live in same test file). + +Manual QA: **none** (infrastructure-only). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs` | Ops-level stable deny codes including `invalid_delta` | +| `server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs` | Structured apply result with optional audit row | +| `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs` | `TryApplyDelta` orchestration + compensating rollback | +| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | AAA unit + host DI smoke tests | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/README.md` | Document `ReputationOperations.TryApplyDelta`, commit order, rollback policy, and “do not call standing store alone for game apply” | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-04 in progress / landed when shipped | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-136 plan + shipped ops when complete | + +## Tests + +| File | Action | Coverage | +|------|--------|----------| +| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | **Add** | Happy path, clamp min/max, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, host DI smoke | + +No new Postgres integration tests — NEO-135 already covers store persistence; ops layer is store-agnostic. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Standing rollback fails after audit append deny | Document as prototype invariant violation (same as NEO-62 depletion edge); no cross-store transaction | `deferred` | +| Negative `deltaAmount` for future rep loss | **Allow** signed delta at ops layer; content grants are positive only | `adopted` | +| Caller-generated `deltaId` vs auto UUID | **Caller supplies id** per NEO-135; NEO-138 can derive from delivery key + faction | `adopted` | +| Duplicate `deltaId` on retry after rollback | Append fails → rollback → caller may retry with **new** id; quest idempotency at delivery store prevents double call in NEO-138 | `adopted` | + +**NEO-138** (`RewardRouterOperations` rep grants) and **NEO-141** (telemetry comments) depend on this orchestration landing first. diff --git a/docs/reviews/2026-06-15-NEO-136.md b/docs/reviews/2026-06-15-NEO-136.md new file mode 100644 index 0000000..82a265a --- /dev/null +++ b/docs/reviews/2026-06-15-NEO-136.md @@ -0,0 +1,53 @@ +# Code review — NEO-136 (E7M3-04) + +**Date:** 2026-06-15 +**Scope:** Branch `NEO-136-e7m3-reputation-operations-apply-delta` — commits `e9abc8b` … `536f635` vs `main` +**Follow-up:** Review findings addressed in follow-up commit (applied-delta rollback, ops id normalization, AAA nit). + +**Base:** `main` + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands **E7M3-04**: server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation via **`IFactionStandingStore`**, append-only audit via **`IReputationDeltaStore`**, and compensating standing rollback when audit append fails. Supporting types **`ReputationApplyOutcome`** and **`ReputationApplyReasonCodes`** cover ops-level pre-flight denies and store passthrough. Ten AAA unit tests plus host DI smoke exercise happy path, clamp, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, and factory resolution. **`780`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or Godot wiring (NEO-138+). One non-blocking correctness gap: compensating rollback uses `-deltaAmount` rather than the **actual applied delta** when clamping occurred, which can leave wrong standing on rare audit-append failure after a clamped apply. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-136-implementation-plan.md` | **Matches** — shipped ops, types, tests, README, module/alignment updates reconcile with plan; AC checklist marked complete. Plan flow step 1 cites explicit `FactionStandingIds` normalization at ops layer; implementation delegates player/faction normalization to stores (see Suggestions). | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-04 scope (auditable apply orchestration, no client counterpart, no HTTP) aligns with implementation. | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-04 `ReputationOperations` landed (NEO-136). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-04 / NEO-136. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. | +| `server/README.md` | **Matches** — new ReputationOperations section documents commit order, rollback policy, pre-flight denies, and “do not call standing store alone.” | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Compensating rollback delta when clamping** — On audit append failure, rollback uses `-deltaAmount`:~~ **Done.** Rollback now uses `-appliedDelta` where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding`; audit row records applied delta. Test `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` added. + +2. ~~**Ops-layer id normalization (plan alignment)** — Plan §2 step 1 says normalize via **`FactionStandingIds`** before store calls.~~ **Done.** `TryApplyDelta` normalizes player/faction ids at the ops boundary; audit row built from normalized values. + +## Nits + +- ~~Nit: **`TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath`** — `auditStore.GetDeltasForPlayer(PlayerId)` runs in the **Act** phase; per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), verification reads belong in **Assert**.~~ **Done.** `GetDeltasForPlayer` moved to Assert. +- Nit: **`ReputationApplyOutcome`** on store-boundary deny returns standings from the mutation outcome (good); pre-flight **`Deny()`** zeroes standings — callers must not treat `0` as “neutral read” on `invalid_delta` / `invalid_source` denies (documented implicitly by reason codes). +- Nit: Telemetry hook comment for NEO-141 is correctly placed on the success path only — no action needed. + +## Verification + +Commands run during review (passed): + +```bash +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ReputationOperationsTests" +dotnet test NeonSprawl.sln +``` + +Author should confirm PR CI green before merge. No manual QA doc required (server engine only per plan). **Intentional follow-on:** reward bundle rep wiring (NEO-138), quest accept gate eval (NEO-137), HTTP GET standing (NEO-139), Godot HUD (NEO-142+). diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs new file mode 100644 index 0000000..68f9861 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs @@ -0,0 +1,359 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public sealed class ReputationOperationsTests +{ + 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"; + private const string QuestSourceId = "prototype_quest_operator_chain"; + private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + var timeProvider = new FakeTimeProvider(RecordedAt); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 15, + "delta-001", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + timeProvider); + // Assert + Assert.True(outcome.Success); + Assert.Null(outcome.ReasonCode); + Assert.Equal(0, outcome.PreviousStanding); + Assert.Equal(15, outcome.NewStanding); + Assert.NotNull(outcome.AuditRow); + Assert.Equal("delta-001", outcome.AuditRow!.Id); + Assert.Equal(15, outcome.AuditRow.DeltaAmount); + Assert.Equal(15, outcome.AuditRow.ResultingStanding); + Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, outcome.AuditRow.SourceKind); + Assert.Equal(QuestSourceId, outcome.AuditRow.SourceId); + Assert.Equal(RecordedAt, outcome.AuditRow.RecordedAt); + var rows = auditStore.GetDeltasForPlayer(PlayerId); + var read = Assert.Single(rows); + Assert.Equal(outcome.AuditRow, read); + Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 50, + "delta-max", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.True(outcome.Success); + Assert.Equal(95, outcome.PreviousStanding); + Assert.Equal(100, outcome.NewStanding); + Assert.Equal(5, outcome.AuditRow!.DeltaAmount); + Assert.Equal(100, outcome.AuditRow.ResultingStanding); + Assert.Equal(100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + -50, + "delta-min", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.True(outcome.Success); + Assert.Equal(-95, outcome.PreviousStanding); + Assert.Equal(-100, outcome.NewStanding); + Assert.Equal(-5, outcome.AuditRow!.DeltaAmount); + Assert.Equal(-100, outcome.AuditRow.ResultingStanding); + Assert.Equal(-100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public void TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + "not_a_real_faction", + 10, + "delta-unknown", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode); + Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 0, + "delta-zero", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(outcome.Success); + Assert.Equal(ReputationApplyReasonCodes.InvalidDelta, outcome.ReasonCode); + Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 10, + " ", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(outcome.Success); + Assert.Equal(ReputationApplyReasonCodes.InvalidDeltaId, outcome.ReasonCode); + Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + // Act + var emptyKind = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 10, + "delta-source-kind", + " ", + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + var emptyId = ReputationOperations.TryApplyDelta( + PlayerId, + RustFactionId, + 10, + "delta-source-id", + ReputationDeltaSourceKinds.QuestCompletion, + "", + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(emptyKind.Success); + Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyKind.ReasonCode); + Assert.False(emptyId.Success); + Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyId.ReasonCode); + Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Equal(0, standingStore.TryGetStanding(PlayerId, RustFactionId).Standing); + Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void TryApplyDelta_ShouldRollbackStanding_WhenAuditAppendFails() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + Assert.True(auditStore.TryAppend(CreateAuditRow("delta-conflict", 1, 1))); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 15, + "delta-conflict", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(outcome.Success); + Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode); + Assert.Equal(0, outcome.PreviousStanding); + Assert.Equal(0, outcome.NewStanding); + Assert.Null(outcome.AuditRow); + Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Single(auditStore.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success); + Assert.True(auditStore.TryAppend(CreateAuditRow("delta-clamp-conflict", 1, 1))); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 50, + "delta-clamp-conflict", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(outcome.Success); + Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode); + Assert.Equal(95, outcome.PreviousStanding); + Assert.Equal(95, outcome.NewStanding); + Assert.Null(outcome.AuditRow); + Assert.Equal(95, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Single(auditStore.GetDeltasForPlayer(PlayerId)); + } + + [Fact] + public void TryApplyDelta_ShouldDenyNonWritablePlayer() + { + // Arrange + var standingStore = CreateStandingStore(); + var auditStore = CreateAuditStore(); + // Act + var outcome = ReputationOperations.TryApplyDelta( + UnknownPlayerId, + GridFactionId, + 10, + "delta-player", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.False(outcome.Success); + Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode); + Assert.Empty(auditStore.GetDeltasForPlayer(UnknownPlayerId)); + } + + [Fact] + public async Task Host_ShouldApplyReputationDeltaViaOps_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + var standingStore = factory.Services.GetRequiredService(); + var auditStore = factory.Services.GetRequiredService(); + // Act + var outcome = ReputationOperations.TryApplyDelta( + PlayerId, + GridFactionId, + 15, + "delta-host", + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + standingStore, + auditStore, + TimeProvider.System); + // Assert + Assert.True(outcome.Success); + Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + Assert.Single(auditStore.GetDeltasForPlayer(PlayerId)); + } + + private static InMemoryFactionStandingStore CreateStandingStore() + { + var registry = CreatePrototypeRegistry(); + var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); + return new InMemoryFactionStandingStore(options, registry); + } + + private static InMemoryReputationDeltaStore CreateAuditStore() => new(); + + private static ReputationDeltaRow CreateAuditRow(string id, int delta, int resulting) => + new( + id, + PlayerId, + GridFactionId, + delta, + resulting, + ReputationDeltaSourceKinds.QuestCompletion, + QuestSourceId, + RecordedAt); + + 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); + } + + private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs b/server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs new file mode 100644 index 0000000..50c2f3a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-136). +public readonly record struct ReputationApplyOutcome( + bool Success, + string? ReasonCode, + int PreviousStanding, + int NewStanding, + ReputationDeltaRow? AuditRow); diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs b/server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs new file mode 100644 index 0000000..4ccd60a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable deny reason codes for (NEO-136). +public static class ReputationApplyReasonCodes +{ + public const string InvalidDelta = "invalid_delta"; + + public const string InvalidDeltaId = "invalid_delta_id"; + + public const string InvalidSource = "invalid_source"; + + public const string AuditAppendFailed = "audit_append_failed"; +} diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs b/server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs new file mode 100644 index 0000000..0343fa5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs @@ -0,0 +1,93 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Orchestrates auditable faction standing apply: standing mutation + append-only audit row (NEO-136). +/// Quest reward wiring: (NEO-138). +/// NEO-141 telemetry hook site: successful apply path in . +/// +public static class ReputationOperations +{ + /// + /// Applies a signed standing delta for one player+faction, clamps to faction bounds, and appends one audit row. + /// Callers must not invoke alone for game apply. + /// + public static ReputationApplyOutcome TryApplyDelta( + string playerId, + string factionId, + int deltaAmount, + string deltaId, + string sourceKind, + string sourceId, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, + TimeProvider timeProvider) + { + if (deltaAmount == 0) + { + return Deny(ReputationApplyReasonCodes.InvalidDelta); + } + + var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId); + var normalizedFactionId = FactionStandingIds.NormalizeFactionId(factionId); + var normalizedDeltaId = deltaId.Trim(); + if (normalizedDeltaId.Length == 0) + { + return Deny(ReputationApplyReasonCodes.InvalidDeltaId); + } + + var normalizedSourceKind = sourceKind.Trim(); + var normalizedSourceId = sourceId.Trim(); + if (normalizedSourceKind.Length == 0 || normalizedSourceId.Length == 0) + { + return Deny(ReputationApplyReasonCodes.InvalidSource); + } + + var mutation = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, deltaAmount); + if (!mutation.Success) + { + return new ReputationApplyOutcome( + false, + mutation.ReasonCode, + mutation.PreviousStanding, + mutation.NewStanding, + null); + } + + var appliedDelta = mutation.NewStanding - mutation.PreviousStanding; + var row = new ReputationDeltaRow( + normalizedDeltaId, + normalizedPlayerId, + normalizedFactionId, + appliedDelta, + mutation.NewStanding, + normalizedSourceKind, + normalizedSourceId, + timeProvider.GetUtcNow()); + + if (auditStore.TryAppend(row)) + { + // --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` --- + return new ReputationApplyOutcome( + true, + null, + mutation.PreviousStanding, + mutation.NewStanding, + row); + } + + if (appliedDelta != 0) + { + _ = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, -appliedDelta); + } + + return new ReputationApplyOutcome( + false, + ReputationApplyReasonCodes.AuditAppendFailed, + mutation.PreviousStanding, + mutation.PreviousStanding, + null); + } + + private static ReputationApplyOutcome Deny(string reasonCode) => + new(false, reasonCode, 0, 0, null); +} diff --git a/server/README.md b/server/README.md index 78f6b0e..1aedd94 100644 --- a/server/README.md +++ b/server/README.md @@ -82,6 +82,20 @@ Append-only **`ReputationDelta`** audit rows live in **`IReputationDeltaStore`** **Storage:** in-memory singletons when **`ConnectionStrings:NeonSprawl`** is unset (standing store seeds configured dev player only). When Postgres is configured, **`PostgresFactionStandingStore`** persists to **`player_faction_standing`** ([`V009__player_faction_standing.sql`](../db/migrations/V009__player_faction_standing.sql)) and **`PostgresReputationDeltaStore`** appends to **`reputation_delta_audit`** ([`V010__reputation_delta_audit.sql`](../db/migrations/V010__reputation_delta_audit.sql)). Plan: [NEO-135 implementation plan](../../docs/plans/NEO-135-implementation-plan.md). +## ReputationOperations (NEO-136) + +Game code applies standing changes through **`ReputationOperations.TryApplyDelta`** — not **`IFactionStandingStore.TryApplyStandingDelta`** alone — so every mutation records source attribution and an append-only audit row. + +**`TryApplyDelta`** parameters: `playerId`, `factionId`, signed `deltaAmount`, caller-supplied `deltaId` (UUID string), `sourceKind`, `sourceId`, plus injected **`IFactionStandingStore`**, **`IReputationDeltaStore`**, and **`TimeProvider`**. + +**Commit order:** standing apply first, audit append second. Audit row **`deltaAmount`** records the **applied** change (`newStanding - previousStanding`), not the requested delta when clamping occurs. On audit append failure (`duplicate` id or invalid row), the operation **compensating-rollback** standing via the inverse applied delta and returns **`audit_append_failed`**. + +**Pre-flight denies (no store mutation):** `deltaAmount == 0` → **`invalid_delta`**; empty `deltaId` → **`invalid_delta_id`**; empty `sourceKind` or `sourceId` → **`invalid_source`**. Store boundary denies (**`unknown_faction`**, **`player_not_writable`**) pass through unchanged. + +**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring lands in NEO-138 (`RewardRouterOperations`). + +Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-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.