From 718346163f880fb9adb5ca309edf9e78b13efa90 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 14 Jun 2026 14:51:22 -0400 Subject: [PATCH 1/5] =?UTF-8?q?NEO-133:=20kickoff=20E7M3-01=20=E2=80=94=20?= =?UTF-8?q?faction=20catalog=20schemas=20+=20CI=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land E7.M3 Slice 3 decomposition docs and NEO-133 implementation plan before content/schema work; blocks NEO-134 server faction load. --- .../epics/epic_07_quest_faction.md | 12 +- .../modules/E7_M3_FactionReputationLedger.md | 60 ++- ...umentation_and_implementation_alignment.md | 1 + .../modules/module_dependency_register.md | 4 +- docs/plans/E7M1-prototype-backlog.md | 2 +- docs/plans/E7M2-prototype-backlog.md | 2 +- docs/plans/E7M3-pre-production-backlog.md | 379 ++++++++++++++++++ docs/plans/NEO-133-implementation-plan.md | 103 +++++ 8 files changed, 548 insertions(+), 15 deletions(-) create mode 100644 docs/plans/E7M3-pre-production-backlog.md create mode 100644 docs/plans/NEO-133-implementation-plan.md diff --git a/docs/decomposition/epics/epic_07_quest_faction.md b/docs/decomposition/epics/epic_07_quest_faction.md index 01bcdd0..99f2c5d 100644 --- a/docs/decomposition/epics/epic_07_quest_faction.md +++ b/docs/decomposition/epics/epic_07_quest_faction.md @@ -34,7 +34,7 @@ Provide a quest state machine and reward routing that ties gathering, crafting, - Responsibility: Track faction standing and gate faction-specific contracts/content. - Key contracts: `FactionStanding`, `ReputationDelta`, `FactionGateRule` -- Dependencies: E7.M1 +- Dependencies: E7.M1, E7.M2 - Stage target: Pre-production ### E7.M4 - ContractMissionGenerator @@ -68,14 +68,19 @@ Provide a quest state machine and reward routing that ties gathering, crafting, **Linear backlog:** [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) — [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Verify quest completion rewards in **Godot**, not Bruno-only. Encounter loot ([E5.M3](../modules/E5_M3_EncounterAndRewardTables.md)) unchanged in Slice 2. + + ### Slice 3 - Faction ledger (pre-production) - Scope: E7.M3 gating at least one faction line and mission set. -- Dependencies: E7.M1 +- Dependencies: E7.M1, E7.M2 - Acceptance criteria: - Reputation deltas auditable; gates fail closed on tamper. + - Player earns Grid Operators rep from operator chain; faction-gated quest accept denied below threshold. - Telemetry hooks: `reputation_delta`, `faction_gate_blocked`. +**Linear backlog:** [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) — [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Verify faction standing + gate deny in **Godot**, not Bruno-only. + ### Slice 4 - Contract generator (pre-production) - Scope: E7.M4 emitting repeatable contracts from templates by zone difficulty. @@ -97,4 +102,5 @@ Provide a quest state machine and reward routing that ties gathering, crafting, - Prototype quest counts and integrated chain meet master plan minimums **in the Godot client** (Slice 1 capstone [NEO-123](../../manual-qa/NEO-123.md)), not Bruno-only. - Slice 2 quest **completion** rewards (items + skill XP) visible in Godot with idempotent delivery (capstone [NEO-132](../../manual-qa/NEO-132.md)), not Bruno-only. -- Pre-production adds faction + contracts with validation. +- Slice 3 faction rep + gated quest line visible in Godot (capstone [NEO-143](../../manual-qa/NEO-143.md)), not Bruno-only. +- Pre-production adds contracts (E7.M4) with validation beyond Slice 3. diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 7a1bf29..79e6b30 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,38 +7,80 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Pre-production | -| **Status** | Planned (see [dependency register](module_dependency_register.md)) | +| **Status** | In Progress — Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-01** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) (decomposed). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | +| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | ## Purpose -Faction standing, reputation deltas, and `FactionGateRule` evaluation for content and contracts. +Character-wide **faction standing**, auditable **reputation deltas**, and **`FactionGateRule`** evaluation for quest accept (and future zone/travel gates via [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)). Rep grants flow through [E7.M2](E7_M2_RewardAndUnlockRouter.md) **`completionRewardBundle`** idempotency. ## Responsibilities -- Persist auditable `FactionStanding`; apply `ReputationDelta`; fail closed on tamper for gates. +- Persist **`FactionStanding`** per player per faction; apply **`ReputationDelta`** with clamp and audit log. +- Evaluate **`FactionGateRule`** on quest accept — fail closed on unknown faction or tampered rules (startup validation). +- Emit telemetry hook sites: **`reputation_delta`**, **`faction_gate_blocked`**. ## Key contracts | Contract | Role | |----------|------| -| `FactionStanding` | Current rep per faction. | -| `ReputationDelta` | Change events with source attribution. | -| `FactionGateRule` | Unlock conditions for content. | +| `FactionDef` | Faction id, display name, min/max/neutral standing. | +| `FactionStanding` | Current rep per faction for a player. | +| `ReputationDelta` | Signed change with source attribution (quest id, etc.). | +| `FactionGateRule` | Minimum standing required (e.g. on `QuestDef`). | +| `reputationGrants` | Rows on `QuestRewardBundle` — `factionId` + `amount`. | ## Module dependencies -- **E7.M1** — QuestStateMachine. +- **E7.M1** — QuestStateMachine (`TryAccept` gate hook site). +- **E7.M2** — RewardAndUnlockRouter (bundle apply + idempotent delivery store). ## Dependents (by design) - **E7.M4** — ContractMissionGenerator. - **E8.M2** — GuildCorpProgressionState. +- **E4.M1** — ZoneGraphAndTravelRules (`TravelRule` faction gates). ## Related implementation slices -Epic 7 **Slice 3** — `reputation_delta`, `faction_gate_blocked`. +Epic 7 **Slice 3** — at least one faction quest line with rep grant + gated accept; auditable deltas; telemetry **`reputation_delta`**, **`faction_gate_blocked`**. Backlog: [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md). + +## Linear backlog (decomposed) + +| Slug | Linear | +|------|--------| +| E7M3-01 | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) | +| E7M3-02 | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) | +| E7M3-03 | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) | +| E7M3-04 | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) | +| E7M3-05 | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) | +| E7M3-06 | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) | +| E7M3-07 | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) | +| E7M3-08 | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) | +| E7M3-09 | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) | +| E7M3-10 | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) | +| E7M3-11 | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) | + +## Prototype Slice 3 freeze (E7M3-01) + +### Faction catalog + +| `FactionDef.id` | `displayName` | Standing band | +|-----------------|---------------|---------------| +| **`prototype_faction_grid_operators`** | Grid Operators | -100…100 (neutral 0) | +| **`prototype_faction_rust_collective`** | Rust Collective | -100…100 (neutral 0) | + +### Quest content changes + +| `QuestDef.id` | Rep / gate | +|---------------|------------| +| **`prototype_quest_operator_chain`** | **`completionRewardBundle.reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (existing item + skill XP unchanged) | +| **`prototype_quest_grid_contract`** *(new)* | **`prerequisiteQuestIds`**: operator chain; **`factionGateRules`**: Grid Operators **`minStanding` 15**; objective **`inventory_has_item`** **`survey_drone_kit` ×1**; completion rep **Rust Collective +10** | + +**Roster:** five quests total (four E7.M1 onboarding + one faction line). E7.M1 four-quest freeze expands under E7M3 CI gates. ## Source anchors -- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. +- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7, faction reputation. +- Game design: [skills.md — Rep / story](../../../docs/game-design/skills.md) (character-wide faction rep). - [Module dependency register](module_dependency_register.md) diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 909193a..80edc77 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,6 +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 | **Decomposed** — Epic 7 Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) **E7M3-01** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions (**Grid Operators**, **Rust Collective**); operator-chain **`reputationGrants`** (+15 Grid Operators); new gated quest **`prototype_quest_grid_contract`** (**`minStanding` 15**); **`faction_gate_blocked`** accept deny; rep apply via extended **`RewardRouterOperations`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [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/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 1da837b..a2b12ea 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -113,7 +113,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl | E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready | **E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`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. -| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | +| E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | In Progress | + +**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. Client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned | ### Epic 8 — Social / Guild diff --git a/docs/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index 159439a..af3ba24 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -52,7 +52,7 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco | 11 | **E7M1-11** | client | E7M1-08, E7M1-09 | | 12 | **E7M1-12** | client | E7M1-11 | -**Downstream (separate modules):** [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) Slice 2 backlog [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)); [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) faction ledger (pre-production); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests. +**Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests. --- diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index 2b64a24..ccc356c 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -46,7 +46,7 @@ Working backlog for **Epic 7 — Slice 2** ([reward and unlock routing](../decom | 8 | **E7M2-08** | client | E7M2-06 | | 9 | **E7M2-09** | client | E7M2-08 | -**Downstream (separate modules):** [E6.M4](../decomposition/modules/E6_M4_RewardParityEnforcer.md) lint against bundles; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) reputation grants (pre-production); E5.M3 **`EncounterCompleteEvent`** full router consumer (optional follow-on). +**Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests. --- diff --git a/docs/plans/E7M3-pre-production-backlog.md b/docs/plans/E7M3-pre-production-backlog.md new file mode 100644 index 0000000..b3400fc --- /dev/null +++ b/docs/plans/E7M3-pre-production-backlog.md @@ -0,0 +1,379 @@ +# E7.M3 — Pre-production story backlog (FactionReputationLedger) + +Working backlog for **Epic 7 — Slice 3** ([faction ledger](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production)). Decomposition and contracts: [E7.M3 — FactionReputationLedger](../decomposition/modules/E7_M3_FactionReputationLedger.md). + +**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E7M2 (paired server+client)](../plans/E7M2-prototype-backlog.md), [E7M1 (quest HUD + capstone)](../plans/E7M1-prototype-backlog.md). + +**Labels (Linear):** every issue **`E7.M3`**; add **`server`** or **`client`** + **`Story`** as listed. + +**Precursor (do not re-scope):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) **Ready** — quest accept/advance/complete ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)–[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)). [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **Ready** — idempotent **`completionRewardBundle`** delivery ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)). Slice 3 **extends** E7.M2 bundles with **`reputationGrants`**; does not migrate encounter loot or duplicate quest completion grants. + +**Upstream (must be landed):** E7.M1 quest state + accept gates; E7.M2 **`RewardRouterOperations`** + **`IRewardDeliveryStore`**; [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md) solo idempotency. + +**Prototype faction spine (frozen in E7M3-01):** **two** frozen `FactionDef` ids; **one** reputation grant on **`prototype_quest_operator_chain`**; **one** faction-gated follow-on quest **`prototype_quest_grid_contract`**. + +**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts. + +| Slug | Layer | Linear | +|------|-------|--------| +| E7M3-01 | server | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) | +| E7M3-02 | server | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) | +| E7M3-03 | server | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) | +| E7M3-04 | server | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) | +| E7M3-05 | server | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) | +| E7M3-06 | server | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) | +| E7M3-07 | server | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) | +| E7M3-08 | server | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) | +| E7M3-09 | server | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) | +| E7M3-10 | client | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) | +| E7M3-11 | client | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) | + +**Dependency graph in Linear:** E7M3-02 blocked by E7M3-01. E7M3-03 blocked by E7M3-02. E7M3-04 blocked by E7M3-03. E7M3-05 blocked by E7M3-02 and E7M3-03. E7M3-06 blocked by E7M3-04. E7M3-07 blocked by E7M3-03. E7M3-08 blocked by E7M3-06 and E7M3-07. E7M3-09 blocked by E7M3-04 and E7M3-05. E7M3-10 blocked by E7M3-07 and E7M3-08. E7M3-11 blocked by E7M3-10. + +**Board order:** estimates **1–11** matching slug order (E7M3-01 = 1 … E7M3-11 = 11). On the Epic 7 project board, sort by **Estimate** (ascending). + +--- + +## Story order (recommended) + +| Order | Slug | Layer | Depends on | +|-------|------|-------|------------| +| 1 | **E7M3-01** | server | E7.M1/E7.M2 content frozen | +| 2 | **E7M3-02** | server | E7M3-01 | +| 3 | **E7M3-03** | server | E7M3-02 | +| 4 | **E7M3-04** | server | E7M3-03 | +| 5 | **E7M3-05** | server | E7M3-02, E7M3-03 | +| 6 | **E7M3-06** | server | E7M3-04 | +| 7 | **E7M3-07** | server | E7M3-03 | +| 8 | **E7M3-08** | server | E7M3-06, E7M3-07 | +| 9 | **E7M3-09** | server | E7M3-04, E7M3-05 | +| 10 | **E7M3-10** | client | E7M3-07, E7M3-08 | +| 11 | **E7M3-11** | client | E7M3-10 | + +**Downstream (separate modules):** [E7.M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md) contract generator; [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) **`TravelRule`** faction gates; [E8.M2](../decomposition/modules/E8_M2_GuildCorpProgressionState.md) guild/corp rep coupling. + +--- + +## Kickoff decisions (decomposition defaults) + +| Topic | Decision | Rationale | +|-------|----------|-----------| +| Faction count | **Two** frozen ids (corp + street) | Readable contrast; Slice 3 AC needs **one** gated line — corp line is flagship | +| Standing range | **-100…100**, neutral **0** | Standard RPG band; clamp on apply | +| Rep grants | **`reputationGrants`** rows on **`completionRewardBundle`** | Reuse E7.M2 idempotent delivery path; audit via delivery store | +| Gate evaluation | **`TryAccept`** only (quest accept) | E4.M1 travel gates deferred; epic Slice 3 is quest/mission gating | +| Gate deny | **`faction_gate_blocked`** reason code | Epic telemetry vocabulary | +| Fail-closed | Unknown **`factionId`** in grant or gate → deny | Tamper / bad content cannot silently pass | +| Delta audit | Append-only **`ReputationDelta`** log per apply | Slice 3 AC: auditable deltas | +| Idempotency | Rep grants ride **`{playerId}:quest_complete:{questId}`** delivery key | Same as E7.M2; replay does not double-apply standing | +| Party mode | **Solo only** | E8.M1 not landed | +| Quest roster | **Five** quests (four E7.M1 + one faction line) | Operator chain grants rep; gated quest proves accept gate | +| HUD fidelity | Prototype labels / debug panels | NEO-122 / NEO-131 precedent | + +### Prototype faction freeze (E7M3-01) + +| `FactionDef.id` | `displayName` | `minStanding` | `maxStanding` | `neutralStanding` | +|-----------------|---------------|---------------|---------------|---------------------| +| **`prototype_faction_grid_operators`** | Grid Operators | -100 | 100 | 0 | +| **`prototype_faction_rust_collective`** | Rust Collective | -100 | 100 | 0 | + +### Prototype reputation + gate freeze (E7M3-01) + +| Quest id | Change | Detail | +|----------|--------|--------| +| **`prototype_quest_operator_chain`** | Extend **`completionRewardBundle`** | Add **`reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (item + skill XP rows unchanged) | +| **`prototype_quest_grid_contract`** | **New** quest | **`prerequisiteQuestIds`**: **`prototype_quest_operator_chain`**; **`factionGateRules`**: **`prototype_faction_grid_operators`** **`minStanding` 15**; 1 step **`inventory_has_item`** **`survey_drone_kit` ×1**; completion bundle: **`prototype_faction_rust_collective`** **+10** rep only | + +Accepting **`prototype_quest_grid_contract`** before operator-chain completion or before **15** Grid Operators standing returns **`faction_gate_blocked`** or **`prerequisite_incomplete`** respectively. + +--- + +### E7M3-01 — FactionDef catalog + quest gate/rep schemas + CI + +**Goal:** Lock faction content shape, extend quest schemas for gates + rep grants, and CI validation before server load. + +**In scope** + +- `content/schemas/faction-def.schema.json`, `content/schemas/faction-gate-rule.schema.json`, `content/schemas/reputation-grant-row.schema.json`. +- Extend `content/schemas/quest-def.schema.json` with optional **`factionGateRules`** array. +- Extend `content/schemas/quest-reward-bundle.schema.json` with optional **`reputationGrants`** array. +- `content/factions/prototype_factions.json` — two frozen faction rows. +- Update `content/quests/prototype_quests.json` — operator-chain rep grant + fifth quest per freeze table. +- `scripts/validate_content.py`: faction schema validation, quest gate cross-refs, rep grant cross-refs, five-quest allowlist (four E7.M1 + **`prototype_quest_grid_contract`**), E7M2 bundle freeze updated for operator-chain rep row. +- Designer note in [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) + `content/README.md`. + +**Out of scope** + +- Server loader, stores, operations, HTTP, Godot. + +**Acceptance criteria** + +- [ ] PR gate validates faction JSON and extended quest JSON. +- [ ] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs. +- [ ] Invalid faction refs in gates or grants fail CI. + +**Client counterpart:** none (infrastructure-only). + +--- + +### E7M3-02 — Server faction catalog load (fail-fast) + +**Goal:** Disk → host: startup load of `content/factions/*_factions.json` with CI-parity validation. + +**In scope** + +- Loader + registry types under `server/NeonSprawl.Server/Game/Factions/` (or `Game/Reputation/`). +- **`IFactionDefinitionRegistry`** + DI registration. +- Cross-check quest **`factionGateRules`** / bundle **`reputationGrants`** faction ids at startup (after quest catalog load). +- Unit tests (AAA) for happy path and broken faction refs. +- `server/README.md` section. + +**Out of scope** + +- Per-player standing store, apply logic, HTTP. + +**Acceptance criteria** + +- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup. +- [ ] Registry resolves faction metadata by id. + +**Client counterpart:** none (infrastructure-only). + +--- + +### E7M3-03 — Faction standing store + reputation delta audit store + +**Goal:** Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log. + +**In scope** + +- **`IFactionStandingStore`**, **`IReputationDeltaStore`** — in-memory implementations; optional Postgres migrations (NEO-116-style split). +- Default standing **0** per known faction when missing; clamp reads to faction min/max. +- Audit row: player, faction, delta amount, resulting standing, source kind (`quest_completion`), source id (quest id), timestamp/id. +- Unit tests: first apply, clamp at bounds, audit append. +- `server/README.md` store sections. + +**Out of scope** + +- Public apply API surface (E7M3-04), quest wiring (E7M3-06). + +**Acceptance criteria** + +- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests. +- [ ] Unknown faction id returns structured deny at store boundary. + +**Client counterpart:** none (infrastructure-only). + +--- + +### E7M3-04 — `ReputationOperations` (apply `ReputationDelta`) + +**Goal:** Server-internal apply for standing changes with clamp, audit, and structured outcomes. + +**In scope** + +- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`. +- Validates faction id via **`IFactionDefinitionRegistry`**; fail-closed on unknown faction. +- Emits audit **`ReputationDelta`** row on success. +- Unit + integration tests (AAA): happy path, clamp min/max, unknown faction deny. + +**Out of scope** + +- Quest bundle wiring (E7M3-06), accept gates (E7M3-05). + +**Acceptance criteria** + +- [ ] Delta apply updates standing once and records audit row. +- [ ] Standing never exceeds faction min/max after apply. + +**Client counterpart:** none (server engine). + +--- + +### E7M3-05 — `FactionGateOperations` + wire into `QuestStateOperations.TryAccept` + +**Goal:** Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation. + +**In scope** + +- **`FactionGateOperations.TryEvaluate`** — all rules must pass (AND); compare player standing vs **`minStanding`** per rule. +- Call from **`TryAccept`** after prerequisite check, before **`TryActivate`**. +- New reason code **`faction_gate_blocked`** in **`QuestStateReasonCodes`** + accept deny telemetry hook site. +- Integration tests: accept gated quest denied below threshold; succeeds at threshold; unknown faction in rule fails closed at startup (E7M3-02) not runtime. +- Bruno smoke for accept deny/success on **`prototype_quest_grid_contract`**. + +**Out of scope** + +- Zone/travel gates (E4.M1); Godot HUD (E7M3-10). + +**Acceptance criteria** + +- [ ] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**. +- [ ] Accept succeeds when all rules satisfied. + +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD surfacing (blocked by E7M3-07/E7M3-08). + +--- + +### E7M3-06 — Extend `RewardRouterOperations` for `reputationGrants` + +**Goal:** Quest completion bundles apply faction rep grants idempotently with item/skill XP rows. + +**In scope** + +- Extend **`RewardRouterOperations.TryDeliverQuestCompletion`** to apply **`reputationGrants`** via **`ReputationOperations`** after validation. +- Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** snapshot with rep grant lines. +- Compensating rollback policy: if rep apply fails after items/xp committed, document rollback behavior (mirror E7.M2 partial-failure policy). +- Integration tests: operator-chain completion → +15 Grid Operators once; replay idempotent. +- Extend Bruno quest-progress smoke for standing side effect (optional subfolder). + +**Out of scope** + +- HTTP read projection (E7M3-08), Godot. + +**Acceptance criteria** + +- [ ] First-time quest completion applies rep grants exactly once per delivery key. +- [ ] Unknown faction id in bundle denies delivery before store record. + +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142). + +--- + +### E7M3-07 — `GET /game/players/{id}/faction-standing` HTTP read + +**Goal:** Client-readable standing snapshot for all frozen factions. + +**In scope** + +- **`GET /game/players/{id}/faction-standing`** — `FactionStandingApi` + DTOs in `Game/Factions/`. +- Response lists each catalog faction with current standing (default 0). +- Integration tests + Bruno `bruno/neon-sprawl-server/faction-standing/`. +- `server/README.md` section. + +**Out of scope** + +- Godot HUD (E7M3-10). + +**Acceptance criteria** + +- [ ] GET returns stable JSON for prototype player after quest rep grants. +- [ ] Unknown player returns **404** (position gate precedent). + +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142). + +--- + +### E7M3-08 — Extend quest HTTP projections for rep grants + gate rules + +**Goal:** Client can see gate requirements and completion rep summary without local math. + +**In scope** + +- Extend **`GET /game/world/quest-definitions`** with optional **`factionGateRules`** projection per quest. +- Extend **`GET …/quest-progress`** **`completionRewardSummary`** with **`reputationGrants`** lines (mirror items/skill XP). +- Update **`QuestAcceptApi`** embedded quest rows if applicable. +- Integration tests + Bruno assertions on grid-contract quest row. +- `server/README.md` quest + faction sections. + +**Out of scope** + +- Godot parse/HUD (E7M3-10). + +**Acceptance criteria** + +- [ ] Gated quest definition includes **`factionGateRules`** in world GET. +- [ ] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered. + +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142). + +--- + +### E7M3-09 — Faction telemetry hook sites (comment-only) + +**Goal:** Anchor future E9.M1 catalog events without ingest. + +**In scope** + +- Comment-only **`reputation_delta`** in **`ReputationOperations`** (successful apply). +- Comment-only **`faction_gate_blocked`** in **`FactionGateOperations`** / **`TryAccept`** deny path. +- `server/README.md` hook list. + +**Out of scope** + +- E9.M1 ingest, dashboards. + +**Acceptance criteria** + +- [ ] Hook sites documented in README; no behavior change beyond comments. + +**Client counterpart:** none (infrastructure-only). + +--- + +### E7M3-10 — Client faction standing + gate feedback HUD (Godot) + +**Goal:** Player sees faction standing and accept gate denials in Godot without Bruno. + +**In scope** + +- **`faction_standing_client.gd`**, **`FactionStandingLabel`** (or extend economy HUD section). +- Parse **`GET …/faction-standing`**; refresh after quest completion transitions (reuse **`quest_hud_controller.gd`** signals). +- Surface **`faction_gate_blocked`** on accept feedback label (NEO-122 pattern). +- Extend **`quest_progress_client.gd`** / reward label for **`reputationGrants`** lines in **`completionRewardSummary`**. +- GdUnit parse tests; `client/README.md` section. +- `docs/manual-qa/NEO-142.md` component checklist. + +**Out of scope** + +- Final journal UI; faction art pass. + +**Acceptance criteria** + +- [ ] Standing visible after operator-chain completion (+15 Grid Operators). +- [ ] Accepting grid contract below rep shows readable deny copy on HUD. + +**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140). + +--- + +### E7M3-11 — Playable faction reputation + gate capstone (Godot) + +**Goal:** Prove Epic 7 Slice 3 acceptance **in Godot**: earn rep from operator chain, accept gated faction quest once; gates fail closed. + +**In scope** + +- **`docs/manual-qa/NEO-143.md`**: capstone session — complete operator chain → verify standing **15** → accept **`prototype_quest_grid_contract`** → complete hand-in → Rust Collective **+10**; retry accept gate before rep (denied). +- **`client/README.md`**: **End-to-end faction reputation loop** section; cross-links NEO-133–NEO-142. +- Module alignment on completion: [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [module_dependency_register.md](../decomposition/modules/module_dependency_register.md). + +**Out of scope** + +- Contract generator (E7.M4); zone travel gates (E4.M1); Bruno-only proof. + +**Acceptance criteria** + +- [ ] Human completes **`docs/manual-qa/NEO-143.md`** with server + client. +- [ ] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (unknown faction denied at startup). +- [ ] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production). + +**Client counterpart:** this story **is** the Slice 3 client capstone. + +--- + +## Decomposition complete checklist + +- [x] Every player-visible AC has a **`client`** Linear issue (NEO-142, NEO-143) +- [x] No forbidden deferral language in backlog +- [x] Capstone client story exists (E7M3-11 / NEO-143) +- [x] Epic DoD updated for Godot verification (see epic_07 update) +- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row updated (decomposition pass) +- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M3 note updated + +## Related docs + +- [epic_07_quest_faction.md](../decomposition/epics/epic_07_quest_faction.md) +- [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md) +- [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) +- [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md) +- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md) diff --git a/docs/plans/NEO-133-implementation-plan.md b/docs/plans/NEO-133-implementation-plan.md new file mode 100644 index 0000000..4c92a18 --- /dev/null +++ b/docs/plans/NEO-133-implementation-plan.md @@ -0,0 +1,103 @@ +# NEO-133 — E7M3-01: FactionDef catalog + quest gate/rep schemas + CI + +**Linear:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) +**Branch:** `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-01** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-124-implementation-plan.md](NEO-124-implementation-plan.md) (schemas + catalog + `validate_content.py` gates) + +## Goal + +Land faction and quest content contracts for Epic 7 Slice 3 so **CI** validates faction JSON, extended quest fields (`factionGateRules`, `completionRewardBundle.reputationGrants`), and the frozen five-quest prototype roster **before** NEO-134 server faction catalog load. + +## Kickoff clarifications + +Decisions are already frozen in [E7M3-pre-production-backlog.md — Kickoff decisions](E7M3-pre-production-backlog.md#kickoff-decisions) and [E7_M3 — Prototype Slice 3 freeze](../decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01). No blocking `AskQuestion` items. + +| Topic | Decision | Evidence | +|-------|----------|----------| +| JSON naming | camelCase: `reputationGrants`, `factionGateRules`, `factionId`, `minStanding` | E7M2 bundle pattern; backlog kickoff table | +| Rep delivery | Via `completionRewardBundle.reputationGrants` (E7.M2 bundle idempotency path) | E7_M3 module doc; NEO-138 applies rep | +| Gate site | Quest **accept** only (Slice 3) | Backlog E7M3-06; zone gates deferred to E4.M1 | +| Faction roster | Exactly two ids: `prototype_faction_grid_operators`, `prototype_faction_rust_collective` | E7_M3 freeze table | +| Quest roster | Five quests: four E7.M1 onboarding + `prototype_quest_grid_contract` | E7_M3 freeze; expands `PROTOTYPE_E7M1_QUEST_IDS` → `PROTOTYPE_E7M3_QUEST_IDS` | +| Operator chain | Add Grid Operators **+15** `reputationGrants`; keep existing item + skill XP | E7_M3 freeze | +| Grid contract | Prereq operator chain; Grid Operators `minStanding` **15**; objective `inventory_has_item` `survey_drone_kit` ×1; completion Rust Collective **+10** | E7_M3 freeze | +| Server loader / stores / HTTP / Godot | **Out of scope** (NEO-134+) | Backlog E7M3-01 scope | +| `dotnet test` green | **Minimal roster parity** on existing E7.M1/E7.M2 catalog rules only (see below) | Content JSON change breaks four-quest server freeze without constant sync | + +### Minimal server parity (CI / test green only) + +Adding the fifth quest and updated `prototype_quests.json` will fail server startup tests until `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` includes five ids. This is **not** NEO-134 loader work — only constant + freeze-table sync mirroring NEO-124 → NEO-125: + +- Extend `ExpectedQuestIds` + add `GridContractQuestId` / objective constants. +- Add `prototype_quest_grid_contract` to `PrototypeE7M2QuestCatalogRules.ExpectedCompletionBundles` with **empty** `itemGrants` / `skillXpGrants` (rep-only bundle; server `ParseCompletionRewardBundle` ignores `reputationGrants` until NEO-138). +- Operator chain JSON may include `reputationGrants`; server E7M2 freeze still compares item + skill XP only (unchanged normalized bundle). + +No new faction loader, no `IFactionDefinitionRegistry`, no gate evaluation. + +## Files to add + +| Path | Purpose | +|------|---------| +| `content/schemas/faction-def.schema.json` | `FactionDef`: `id`, `displayName`, `minStanding`, `maxStanding`, `neutralStanding` | +| `content/schemas/faction-gate-rule.schema.json` | `factionId`, `minStanding` | +| `content/schemas/reputation-grant-row.schema.json` | `factionId`, `amount` (signed int) | +| `content/factions/prototype_factions.json` | Two frozen factions per E7_M3 freeze | + +## Files to modify + +| Path | Change | +|------|--------| +| `content/schemas/quest-def.schema.json` | Optional `factionGateRules` array (`$ref` faction-gate-rule) | +| `content/schemas/quest-reward-bundle.schema.json` | Optional `reputationGrants` array (`$ref` reputation-grant-row) | +| `content/quests/prototype_quests.json` | Operator chain `reputationGrants`; new `prototype_quest_grid_contract` | +| `scripts/validate_content.py` | Faction load/validate; `PROTOTYPE_E7M3_QUEST_IDS` (5); faction cross-ref gates; `PROTOTYPE_E7M3_COMPLETION_BUNDLES` freeze (item + skill + rep); rename/supersede E7M1 four-quest id gate | +| `content/README.md` | Document `content/factions/`, new schemas, five-quest roster | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Mark catalog landed when done | +| `server/.../PrototypeE7M1QuestCatalogRules.cs` | Five-quest roster + grid-contract constants | +| `server/.../PrototypeE7M2QuestCatalogRules.cs` | Grid-contract empty item/skill bundle in freeze | + +## CI gates (`validate_content.py`) + +1. **Faction catalog** — schema validate `content/factions/*_factions.json`; freeze exactly two faction ids + display names + standing band (-100…100, neutral 0). +2. **Quest roster** — `PROTOTYPE_E7M3_QUEST_IDS` frozen set of five ids (includes all four E7M1 ids + `prototype_quest_grid_contract`). +3. **E7M1 gates retained** — acyclic prerequisites, objective cross-refs, chain terminal token (updated for fifth quest). +4. **Faction cross-refs** — every `factionGateRules[].factionId` and `reputationGrants[].factionId` must exist in faction catalog. +5. **E7M3 completion bundle freeze** — per-quest expected `itemGrants`, `skillXpGrants`, `reputationGrants` for all five quests (supersedes E7M2 freeze for operator chain + grid contract rep rows; first three gather/craft quests unchanged). +6. **Grid contract shape** — prereq operator chain; `factionGateRules` Grid Operators minStanding 15; objective `inventory_has_item` / `survey_drone_kit`. + +## Tests + +| Layer | What | +|-------|------| +| CI | `python3 scripts/validate_content.py` (existing PR gate) | +| Server | Existing quest catalog loader/registry tests — should pass after roster parity constants; no new `[Fact]` unless a regression needs explicit grid-contract coverage | + +Manual QA: **none** (content-only; Godot capstone is NEO-143). + +## Implementation order + +1. Schemas (`faction-def`, `faction-gate-rule`, `reputation-grant-row`; extend quest-def + quest-reward-bundle). +2. `prototype_factions.json` + quest JSON edits. +3. `validate_content.py` gates + bundle freeze tables. +4. Minimal server roster/freeze constant sync. +5. `content/README.md` + E7_M3 module doc status line. +6. Run `python3 scripts/validate_content.py` and `dotnet test` on server project. + +## Out of scope (follow-on tickets) + +| Ticket | Work | +|--------|------| +| NEO-134 | Server faction catalog fail-fast load | +| NEO-135–137 | Faction standing store, delta audit, gate evaluation on accept | +| NEO-138 | `RewardRouterOperations` reputation apply + idempotent delivery | +| NEO-139–143 | HTTP read, telemetry, Godot HUD, capstone | + +## Client counterpart + +None for E7M3-01. Client faction HUD and capstone: **NEO-142** / **NEO-143**. + +## Blocks + +**NEO-134** (server faction catalog load) is blocked until this catalog + CI lands. From 0948d9d4a8b27a84fa81dbed844855b88afe7275 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 14 Jun 2026 14:54:20 -0400 Subject: [PATCH 2/5] NEO-133: land faction catalog, quest gate/rep schemas, and CI gates Add two prototype factions, extend quest bundles with reputationGrants and factionGateRules, freeze five-quest roster in validate_content.py, and sync minimal server quest roster parity for green startup tests. --- content/README.md | 7 +- content/factions/prototype_factions.json | 19 + content/quests/prototype_quests.json | 35 +- content/schemas/faction-def.schema.json | 33 ++ content/schemas/faction-gate-rule.schema.json | 20 + content/schemas/quest-def.schema.json | 9 +- .../schemas/quest-reward-bundle.schema.json | 17 +- .../schemas/reputation-grant-row.schema.json | 20 + .../modules/E7_M3_FactionReputationLedger.md | 6 +- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E7M3-pre-production-backlog.md | 6 +- docs/plans/NEO-133-implementation-plan.md | 8 + scripts/validate_content.py | 368 +++++++++++++++++- .../Game/Quests/QuestCatalogTestPaths.cs | 8 + .../QuestDefinitionCatalogLoaderTests.cs | 43 +- .../Quests/QuestDefinitionRegistryTests.cs | 2 +- .../Quests/QuestDefinitionsWorldApiTests.cs | 4 +- .../Quests/PrototypeE7M1QuestCatalogRules.cs | 4 + .../Quests/PrototypeE7M2QuestCatalogRules.cs | 3 + .../Quests/QuestDefinitionCatalogLoader.cs | 3 + 20 files changed, 592 insertions(+), 25 deletions(-) create mode 100644 content/factions/prototype_factions.json create mode 100644 content/schemas/faction-def.schema.json create mode 100644 content/schemas/faction-gate-rule.schema.json create mode 100644 content/schemas/reputation-grant-row.schema.json diff --git a/content/README.md b/content/README.md index cdb789b..51f0360 100644 --- a/content/README.md +++ b/content/README.md @@ -13,7 +13,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with ** | [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | | [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) | | [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) | -| [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) | +| [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) / [E7.M3](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md) | +| [`factions/`](factions/) | Faction catalogs; each row matches [`schemas/faction-def.schema.json`](schemas/faction-def.schema.json) — **stable `id`**, standing band for [E7.M3](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md) | | [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring. @@ -32,7 +33,9 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with ** **Prototype E7 Slice 1 — quests (NEO-112):** CI expects **exactly four** quest ids aligned to [E7.M1 quest freeze](../docs/decomposition/modules/E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01) — **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`**, **`prototype_quest_operator_chain`**. Objective **`itemId`** / **`recipeId`** / **`encounterId`** cross-refs must resolve to frozen item, recipe, and encounter catalogs; **`prerequisiteQuestIds`** must be acyclic and reference known quest ids; operator chain terminal step is **`inventory_has_item`** **`contract_handoff_token`** ×1. **Do not rename** quest `id` after ship—change **`displayName`** only. See [E7M1-prototype-backlog.md](../docs/plans/E7M1-prototype-backlog.md) and [NEO-112 plan](../docs/plans/NEO-112-implementation-plan.md). -**Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each of the four frozen quest ids must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md). +**Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each frozen quest id must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md). + +**Prototype E7 Slice 3 — factions + gated quest line (NEO-133):** CI expects **exactly two** faction ids aligned to [E7.M3 faction freeze](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01) — **`prototype_faction_grid_operators`**, **`prototype_faction_rust_collective`**. Quest roster expands to **five** ids (four E7.M1 onboarding + **`prototype_quest_grid_contract`**). **`factionGateRules[].factionId`** and **`completionRewardBundle.reputationGrants[].factionId`** must resolve to the frozen faction catalog; grid contract shape is frozen in CI. See [E7M3-pre-production-backlog.md](../docs/plans/E7M3-pre-production-backlog.md) and [NEO-133 plan](../docs/plans/NEO-133-implementation-plan.md). **Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45). diff --git a/content/factions/prototype_factions.json b/content/factions/prototype_factions.json new file mode 100644 index 0000000..bb06737 --- /dev/null +++ b/content/factions/prototype_factions.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "factions": [ + { + "id": "prototype_faction_grid_operators", + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + }, + { + "id": "prototype_faction_rust_collective", + "displayName": "Rust Collective", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + } + ] +} diff --git a/content/quests/prototype_quests.json b/content/quests/prototype_quests.json index 4ebae45..32ae65f 100644 --- a/content/quests/prototype_quests.json +++ b/content/quests/prototype_quests.json @@ -76,7 +76,10 @@ ], "completionRewardBundle": { "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], - "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] + "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }], + "reputationGrants": [ + { "factionId": "prototype_faction_grid_operators", "amount": 15 } + ] }, "steps": [ { @@ -128,6 +131,36 @@ ] } ] + }, + { + "id": "prototype_quest_grid_contract", + "displayName": "Grid Contract", + "prerequisiteQuestIds": ["prototype_quest_operator_chain"], + "factionGateRules": [ + { + "factionId": "prototype_faction_grid_operators", + "minStanding": 15 + } + ], + "completionRewardBundle": { + "reputationGrants": [ + { "factionId": "prototype_faction_rust_collective", "amount": 10 } + ] + }, + "steps": [ + { + "id": "grid_contract_step_kit", + "displayName": "Deliver survey drone kit", + "objectives": [ + { + "id": "grid_contract_obj_kit", + "kind": "inventory_has_item", + "itemId": "survey_drone_kit", + "quantity": 1 + } + ] + } + ] } ] } diff --git a/content/schemas/faction-def.schema.json b/content/schemas/faction-def.schema.json new file mode 100644 index 0000000..dd9ab84 --- /dev/null +++ b/content/schemas/faction-def.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/faction-def.json", + "title": "FactionDef", + "description": "Single faction row for catalogs (e.g. content/factions/*_factions.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.", + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "minStanding", "maxStanding", "neutralStanding"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Immutable faction key for standing, gates, telemetry, and reputation grants." + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Player-facing label; safe to change without migrating character data." + }, + "minStanding": { + "type": "integer", + "description": "Lowest standing value after clamp on apply." + }, + "maxStanding": { + "type": "integer", + "description": "Highest standing value after clamp on apply." + }, + "neutralStanding": { + "type": "integer", + "description": "Default standing for new players when no snapshot exists." + } + } +} diff --git a/content/schemas/faction-gate-rule.schema.json b/content/schemas/faction-gate-rule.schema.json new file mode 100644 index 0000000..20b0fd5 --- /dev/null +++ b/content/schemas/faction-gate-rule.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/faction-gate-rule.json", + "title": "FactionGateRule", + "description": "Minimum faction standing required to accept gated content (e.g. on QuestDef.factionGateRules). See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.", + "type": "object", + "additionalProperties": false, + "required": ["factionId", "minStanding"], + "properties": { + "factionId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Faction catalog id that must meet minStanding." + }, + "minStanding": { + "type": "integer", + "description": "Inclusive minimum standing required to pass the gate." + } + } +} diff --git a/content/schemas/quest-def.schema.json b/content/schemas/quest-def.schema.json index 30ccfbd..f1515c4 100644 --- a/content/schemas/quest-def.schema.json +++ b/content/schemas/quest-def.schema.json @@ -33,9 +33,16 @@ }, "description": "Ordered steps; prototype Slice 1 uses sequential advance only." }, + "factionGateRules": { + "type": "array", + "items": { + "$ref": "https://neon-sprawl.local/schemas/faction-gate-rule.json" + }, + "description": "Optional accept gates evaluated against faction standing (E7.M3)." + }, "completionRewardBundle": { "$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json", - "description": "Optional completion payout (E7.M2); required on all four prototype quest ids in CI." + "description": "Optional completion payout (E7.M2/E7.M3); required on all prototype quest ids in CI." } } } diff --git a/content/schemas/quest-reward-bundle.schema.json b/content/schemas/quest-reward-bundle.schema.json index ae2aa8d..34bc6fc 100644 --- a/content/schemas/quest-reward-bundle.schema.json +++ b/content/schemas/quest-reward-bundle.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json", "title": "QuestRewardBundle", - "description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds v1: itemGrants + skillXpGrants only. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md.", + "description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds: itemGrants, skillXpGrants, reputationGrants. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md and E7_M3_FactionReputationLedger.md.", "type": "object", "additionalProperties": false, "properties": { @@ -19,6 +19,13 @@ "$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json" }, "description": "Skill XP rows applied via mission_reward on first-time completion." + }, + "reputationGrants": { + "type": "array", + "items": { + "$ref": "https://neon-sprawl.local/schemas/reputation-grant-row.json" + }, + "description": "Faction reputation rows applied on first-time completion (E7.M3)." } }, "anyOf": [ @@ -37,6 +44,14 @@ "minItems": 1 } } + }, + { + "required": ["reputationGrants"], + "properties": { + "reputationGrants": { + "minItems": 1 + } + } } ] } diff --git a/content/schemas/reputation-grant-row.schema.json b/content/schemas/reputation-grant-row.schema.json new file mode 100644 index 0000000..6dd2a15 --- /dev/null +++ b/content/schemas/reputation-grant-row.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/reputation-grant-row.json", + "title": "ReputationGrantRow", + "description": "Faction reputation payout row on QuestRewardBundle.reputationGrants. See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.", + "type": "object", + "additionalProperties": false, + "required": ["factionId", "amount"], + "properties": { + "factionId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Faction catalog id receiving the standing change." + }, + "amount": { + "type": "integer", + "description": "Signed reputation delta applied on first-time completion delivery." + } + } +} diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 79e6b30..087f9de 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 — Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-01** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) (decomposed). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | +| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)): faction schemas + `prototype_factions.json` + quest gate/rep extensions + CI. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-02** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) → **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 @@ -77,7 +77,9 @@ Epic 7 **Slice 3** — at least one faction quest line with rep grant + gated ac | **`prototype_quest_operator_chain`** | **`completionRewardBundle.reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (existing item + skill XP unchanged) | | **`prototype_quest_grid_contract`** *(new)* | **`prerequisiteQuestIds`**: operator chain; **`factionGateRules`**: Grid Operators **`minStanding` 15**; objective **`inventory_has_item`** **`survey_drone_kit` ×1**; completion rep **Rust Collective +10** | -**Roster:** five quests total (four E7.M1 onboarding + one faction line). E7.M1 four-quest freeze expands under E7M3 CI gates. +**Roster:** five quests total (four E7.M1 onboarding + one faction line). E7.M1 four-quest freeze expands under E7M3 CI gates (`PROTOTYPE_E7M3_QUEST_IDS` in `scripts/validate_content.py`). + +**CI enforcement (NEO-133):** `scripts/validate_content.py` validates `content/factions/*_factions.json`, five-quest roster, faction cross-refs on gates and `reputationGrants`, E7M3 completion bundle freeze, and grid-contract shape. ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 80edc77..b89c922 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 | **Decomposed** — Epic 7 Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) **E7M3-01** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions (**Grid Operators**, **Rust Collective**); operator-chain **`reputationGrants`** (+15 Grid Operators); new gated quest **`prototype_quest_grid_contract`** (**`minStanding` 15**); **`faction_gate_blocked`** accept deny; rep apply via extended **`RewardRouterOperations`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [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 (`factionGateRules`, `reputationGrants`), five-quest CI gates, minimal server roster parity. **E7M3-02+** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) → **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), [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/E7M3-pre-production-backlog.md b/docs/plans/E7M3-pre-production-backlog.md index b3400fc..25f3fb5 100644 --- a/docs/plans/E7M3-pre-production-backlog.md +++ b/docs/plans/E7M3-pre-production-backlog.md @@ -108,9 +108,9 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o **Acceptance criteria** -- [ ] PR gate validates faction JSON and extended quest JSON. -- [ ] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs. -- [ ] Invalid faction refs in gates or grants fail CI. +- [x] PR gate validates faction JSON and extended quest JSON. +- [x] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs. +- [x] Invalid faction refs in gates or grants fail CI. **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-133-implementation-plan.md b/docs/plans/NEO-133-implementation-plan.md index 4c92a18..07eb56f 100644 --- a/docs/plans/NEO-133-implementation-plan.md +++ b/docs/plans/NEO-133-implementation-plan.md @@ -101,3 +101,11 @@ None for E7M3-01. Client faction HUD and capstone: **NEO-142** / **NEO-143**. ## Blocks **NEO-134** (server faction catalog load) is blocked until this catalog + CI lands. + +## Implementation reconciliation (shipped) + +- **Schemas:** `faction-def`, `faction-gate-rule`, `reputation-grant-row`; extended `quest-def` + `quest-reward-bundle`. +- **Catalog:** `prototype_factions.json` (two factions); `prototype_quests.json` operator-chain rep + `prototype_quest_grid_contract`. +- **CI:** `PROTOTYPE_E7M3_*` gates in `validate_content.py` (roster, faction freeze, bundle freeze, cross-refs, grid-contract shape). +- **Server parity:** five-quest `ExpectedQuestIds`, grid-contract E7M2 bundle freeze, schema registry for new `$ref`s. +- **Docs:** `content/README.md` E7 Slice 3 paragraph; E7_M3 module CI line. diff --git a/scripts/validate_content.py b/scripts/validate_content.py index fd578de..7d67034 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -15,7 +15,9 @@ Validates: - reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100) - encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100) - quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112); - optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124) + optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124); + optional factionGateRules + reputationGrants (NEO-133) +- faction catalogs: content/factions/*_factions.json rows vs content/schemas/faction-def.schema.json (NEO-133) """ from __future__ import annotations @@ -46,6 +48,9 @@ QUEST_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json" QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json" QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json" QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json" +FACTION_DEF_SCHEMA = REPO_ROOT / "content/schemas/faction-def.schema.json" +FACTION_GATE_RULE_SCHEMA = REPO_ROOT / "content/schemas/faction-gate-rule.schema.json" +REPUTATION_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reputation-grant-row.schema.json" SKILLS_DIR = REPO_ROOT / "content/skills" MASTERY_DIR = REPO_ROOT / "content/mastery" ITEMS_DIR = REPO_ROOT / "content/items" @@ -56,6 +61,7 @@ NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors" REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables" ENCOUNTERS_DIR = REPO_ROOT / "content/encounters" QUESTS_DIR = REPO_ROOT / "content/quests" +FACTIONS_DIR = REPO_ROOT / "content/factions" # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) @@ -195,6 +201,74 @@ PROTOTYPE_E7M2_COMPLETION_BUNDLES: dict[str, dict] = { }, } +# Epic 7 Slice 3 prototype lock (NEO-133): five-quest roster + faction catalog. +# Keep in sync with E7.M3 freeze table and future NEO-134 server loader. +PROTOTYPE_E7M3_FACTION_IDS = frozenset( + { + "prototype_faction_grid_operators", + "prototype_faction_rust_collective", + } +) +PROTOTYPE_E7M3_FACTION_FREEZE: dict[str, dict] = { + "prototype_faction_grid_operators": { + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0, + }, + "prototype_faction_rust_collective": { + "displayName": "Rust Collective", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0, + }, +} +PROTOTYPE_E7M3_QUEST_IDS = frozenset( + { + "prototype_quest_gather_intro", + "prototype_quest_refine_intro", + "prototype_quest_combat_intro", + "prototype_quest_operator_chain", + "prototype_quest_grid_contract", + } +) +PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID = "prototype_quest_grid_contract" +PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID = "prototype_quest_operator_chain" +PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID = "prototype_faction_grid_operators" +PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING = 15 +PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID = "survey_drone_kit" +PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = { + "prototype_quest_gather_intro": { + "itemGrants": [], + "skillXpGrants": [{"skillId": "salvage", "amount": 25}], + "reputationGrants": [], + }, + "prototype_quest_refine_intro": { + "itemGrants": [], + "skillXpGrants": [{"skillId": "refine", "amount": 25}], + "reputationGrants": [], + }, + "prototype_quest_combat_intro": { + "itemGrants": [], + "skillXpGrants": [{"skillId": "salvage", "amount": 25}], + "reputationGrants": [], + }, + "prototype_quest_operator_chain": { + "itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}], + "skillXpGrants": [{"skillId": "salvage", "amount": 50}], + "reputationGrants": [ + {"factionId": "prototype_faction_grid_operators", "amount": 15} + ], + }, + "prototype_quest_grid_contract": { + "itemGrants": [], + "skillXpGrants": [], + "reputationGrants": [ + {"factionId": "prototype_faction_rust_collective", "amount": 10} + ], + }, +} + def _normalize_completion_bundle(bundle: dict) -> dict: """Normalize grant rows for stable freeze-table comparison.""" @@ -224,6 +298,23 @@ def _normalize_completion_bundle(bundle: dict) -> dict: } +def _normalize_e7m3_completion_bundle(bundle: dict) -> dict: + """Normalize item, skill XP, and reputation grant rows for E7M3 freeze-table comparison.""" + normalized = _normalize_completion_bundle(bundle) + reputation_grants = bundle.get("reputationGrants") + normalized["reputationGrants"] = sorted( + [ + {"factionId": grant["factionId"], "amount": grant["amount"]} + for grant in (reputation_grants if isinstance(reputation_grants, list) else []) + if isinstance(grant, dict) + and isinstance(grant.get("factionId"), str) + and isinstance(grant.get("amount"), int) + ], + key=lambda grant: (grant["factionId"], grant["amount"]), + ) + return normalized + + def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None: """Return a human-readable error if Slice 1 contract fails, else None.""" ids = frozenset(seen_ids.keys()) @@ -895,13 +986,15 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str def _quest_def_validator() -> Draft202012Validator: - """Build a validator that resolves quest-def $ref to step, objective, and bundle schemas.""" + """Build a validator that resolves quest-def $ref to step, objective, bundle, and gate schemas.""" objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8")) step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8")) def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8")) bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8")) skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8")) grant_row_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8")) + faction_gate_rule_schema = json.loads(FACTION_GATE_RULE_SCHEMA.read_text(encoding="utf-8")) + reputation_grant_row_schema = json.loads(REPUTATION_GRANT_ROW_SCHEMA.read_text(encoding="utf-8")) registry = Registry().with_resources( [ (def_schema["$id"], Resource.from_contents(def_schema)), @@ -910,11 +1003,62 @@ def _quest_def_validator() -> Draft202012Validator: (bundle_schema["$id"], Resource.from_contents(bundle_schema)), (skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)), (grant_row_schema["$id"], Resource.from_contents(grant_row_schema)), + (faction_gate_rule_schema["$id"], Resource.from_contents(faction_gate_rule_schema)), + (reputation_grant_row_schema["$id"], Resource.from_contents(reputation_grant_row_schema)), ] ) return Draft202012Validator(def_schema, registry=registry) +def _validate_faction_catalogs( + *, + faction_files: list[Path], + faction_validator: Draft202012Validator, +) -> tuple[int, dict[str, str], dict[str, dict]]: + """Validate faction JSON files. Returns (error_count, seen_ids, id_to_row).""" + errors = 0 + seen_ids: dict[str, str] = {} + id_to_row: dict[str, dict] = {} + + for path in faction_files: + rel = str(path.relative_to(REPO_ROOT)) + data = json.loads(path.read_text(encoding="utf-8")) + schema_version = data.get("schemaVersion") + if schema_version != 1: + print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr) + errors += 1 + continue + + factions = data.get("factions") + if not isinstance(factions, list): + print(f"error: {rel}: expected top-level 'factions' array", file=sys.stderr) + errors += 1 + continue + + for i, row in enumerate(factions): + if not isinstance(row, dict): + print(f"error: {rel}: factions[{i}] must be an object", file=sys.stderr) + errors += 1 + continue + row_schema_errors = 0 + for err in sorted(faction_validator.iter_errors(row), key=lambda e: e.path): + loc = ".".join(str(p) for p in err.path) or "(root)" + print(f"error: {rel} factions[{i}] {loc}: {err.message}", file=sys.stderr) + row_schema_errors += 1 + errors += 1 + fid = row.get("id") + if isinstance(fid, str) and row_schema_errors == 0: + prev = seen_ids.get(fid) + if prev: + print(f"error: duplicate faction id {fid!r} in {prev} and {rel}", file=sys.stderr) + errors += 1 + else: + seen_ids[fid] = rel + id_to_row[fid] = row + + return errors, seen_ids, id_to_row + + def _validate_quest_catalogs( *, quest_files: list[Path], @@ -998,6 +1142,17 @@ def _validate_quest_catalogs( return errors, seen_ids, id_to_row +def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None: + """Return a human-readable error if E7M3 five-quest roster fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_E7M3_QUEST_IDS: + return ( + "error: prototype E7M3 expects exactly quest ids " + f"{sorted(PROTOTYPE_E7M3_QUEST_IDS)!r}, got {sorted(ids)!r}" + ) + return None + + def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None: """Return a human-readable error if E7M1 quest contract fails, else None.""" ids = frozenset(seen_ids.keys()) @@ -1132,7 +1287,7 @@ def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | Non def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None: """Return a human-readable error if a frozen quest lacks completionRewardBundle, else None.""" - for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS): + for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS): row = id_to_row.get(qid) if not isinstance(row, dict): return f"error: missing quest {qid!r}" @@ -1166,7 +1321,7 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate( skill_id_to_allowed_kinds: dict[str, list[str]], ) -> str | None: """Return a human-readable error if bundle refs fail cross-checks, else None.""" - for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS): + for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS): row = id_to_row.get(qid) if not isinstance(row, dict): continue @@ -1206,6 +1361,147 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate( return None +def _prototype_e7m3_faction_roster_gate(faction_seen_ids: dict[str, str]) -> str | None: + """Return a human-readable error if E7M3 faction roster fails, else None.""" + ids = frozenset(faction_seen_ids.keys()) + if ids != PROTOTYPE_E7M3_FACTION_IDS: + return ( + "error: prototype E7M3 expects exactly faction ids " + f"{sorted(PROTOTYPE_E7M3_FACTION_IDS)!r}, got {sorted(ids)!r}" + ) + return None + + +def _prototype_e7m3_faction_freeze_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if faction rows diverge from E7M3 freeze table, else None.""" + for fid, expected in PROTOTYPE_E7M3_FACTION_FREEZE.items(): + row = id_to_row.get(fid) + if not isinstance(row, dict): + return f"error: missing faction {fid!r}" + for key, expected_value in expected.items(): + if row.get(key) != expected_value: + return ( + f"error: faction {fid!r} must match E7M3 freeze table " + f"(expected {key}={expected_value!r}, got {row.get(key)!r})" + ) + return None + + +def _prototype_e7m3_faction_cross_ref_gate( + id_to_row: dict[str, dict], + faction_seen_ids: dict[str, str], +) -> str | None: + """Return a human-readable error if quest faction refs fail, else None.""" + known_factions = frozenset(faction_seen_ids.keys()) + for qid, row in id_to_row.items(): + gate_rules = row.get("factionGateRules") + if isinstance(gate_rules, list): + for gi, rule in enumerate(gate_rules): + if not isinstance(rule, dict): + continue + faction_id = rule.get("factionId") + if isinstance(faction_id, str) and faction_id not in known_factions: + return ( + f"error: quest {qid!r} factionGateRules[{gi}]: factionId {faction_id!r} " + "is not in the frozen prototype faction catalog" + ) + bundle = row.get("completionRewardBundle") + if isinstance(bundle, dict): + reputation_grants = bundle.get("reputationGrants") + if isinstance(reputation_grants, list): + for gi, grant in enumerate(reputation_grants): + if not isinstance(grant, dict): + continue + faction_id = grant.get("factionId") + if isinstance(faction_id, str) and faction_id not in known_factions: + return ( + f"error: quest {qid!r} completionRewardBundle.reputationGrants[{gi}]: " + f"factionId {faction_id!r} is not in the frozen prototype faction catalog" + ) + return None + + +def _prototype_e7m3_completion_bundle_freeze_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if bundle contents diverge from E7M3 freeze table, else None.""" + for qid, expected in PROTOTYPE_E7M3_COMPLETION_BUNDLES.items(): + row = id_to_row.get(qid) + if not isinstance(row, dict): + return f"error: missing quest {qid!r}" + bundle = row.get("completionRewardBundle") + if not isinstance(bundle, dict): + return f"error: quest {qid!r} must include completionRewardBundle object" + actual = _normalize_e7m3_completion_bundle(bundle) + expected_normalized = _normalize_e7m3_completion_bundle(expected) + if actual != expected_normalized: + return ( + f"error: quest {qid!r} completionRewardBundle must match E7M3 freeze table " + f"(expected {expected_normalized!r}, got {actual!r})" + ) + return None + + +def _prototype_e7m3_grid_contract_shape_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if grid contract quest shape fails, else None.""" + row = id_to_row.get(PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID) + if not isinstance(row, dict): + return f"error: missing quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r}" + + prereqs = row.get("prerequisiteQuestIds") + if prereqs != [PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]: + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} prerequisiteQuestIds must be " + f"{[PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]!r}, got {prereqs!r}" + ) + + gate_rules = row.get("factionGateRules") + expected_gate = [ + { + "factionId": PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID, + "minStanding": PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING, + } + ] + if gate_rules != expected_gate: + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} factionGateRules must be " + f"{expected_gate!r}, got {gate_rules!r}" + ) + + steps = row.get("steps") + if not isinstance(steps, list) or len(steps) != 1: + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one step" + ) + step = steps[0] + if not isinstance(step, dict): + return f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} step must be an object" + objectives = step.get("objectives") + if not isinstance(objectives, list) or len(objectives) != 1: + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one objective" + ) + obj = objectives[0] + if not isinstance(obj, dict): + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective must be an object" + ) + if obj.get("kind") != "inventory_has_item": + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective kind must be " + "'inventory_has_item'" + ) + if obj.get("itemId") != PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID: + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective itemId must be " + f"{PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID!r}, got {obj.get('itemId')!r}" + ) + if obj.get("quantity") != 1: + return ( + f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective quantity must be 1, " + f"got {obj.get('quantity')!r}" + ) + return None + + def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None: """Return a human-readable error if Slice 4 contract fails, else None.""" if len(track_skill_ids) != 1: @@ -1556,6 +1852,15 @@ def main() -> int: if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file(): print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr) return 1 + if not FACTION_DEF_SCHEMA.is_file(): + print(f"error: missing schema {FACTION_DEF_SCHEMA}", file=sys.stderr) + return 1 + if not FACTION_GATE_RULE_SCHEMA.is_file(): + print(f"error: missing schema {FACTION_GATE_RULE_SCHEMA}", file=sys.stderr) + return 1 + if not REPUTATION_GRANT_ROW_SCHEMA.is_file(): + print(f"error: missing schema {REPUTATION_GRANT_ROW_SCHEMA}", file=sys.stderr) + return 1 skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_validator = Draft202012Validator(skill_schema) @@ -1578,6 +1883,8 @@ def main() -> int: encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8")) encounter_validator = Draft202012Validator(encounter_schema) quest_validator = _quest_def_validator() + faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8")) + faction_validator = Draft202012Validator(faction_schema) if not SKILLS_DIR.is_dir(): print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) @@ -1679,6 +1986,15 @@ def main() -> int: print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr) return 1 + if not FACTIONS_DIR.is_dir(): + print(f"error: missing directory {FACTIONS_DIR}", file=sys.stderr) + return 1 + + faction_files = sorted(FACTIONS_DIR.glob("*_factions.json")) + if not faction_files: + print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr) + return 1 + seen_ids: dict[str, str] = {} id_to_category: dict[str, str] = {} skill_id_to_allowed_kinds: dict[str, list[str]] = {} @@ -1950,6 +2266,24 @@ def main() -> int: print(e5m3_encounter_cross_ref_err, file=sys.stderr) return 1 + faction_errors, faction_seen_ids, faction_id_to_row = _validate_faction_catalogs( + faction_files=faction_files, + faction_validator=faction_validator, + ) + if faction_errors: + print(f"content validation failed with {faction_errors} error(s)", file=sys.stderr) + return 1 + + e7m3_faction_roster_err = _prototype_e7m3_faction_roster_gate(faction_seen_ids) + if e7m3_faction_roster_err: + print(e7m3_faction_roster_err, file=sys.stderr) + return 1 + + e7m3_faction_freeze_err = _prototype_e7m3_faction_freeze_gate(faction_id_to_row) + if e7m3_faction_freeze_err: + print(e7m3_faction_freeze_err, file=sys.stderr) + return 1 + quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs( quest_files=quest_files, quest_validator=quest_validator, @@ -1958,9 +2292,9 @@ def main() -> int: print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr) return 1 - e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids) - if e7m1_quest_err: - print(e7m1_quest_err, file=sys.stderr) + e7m3_quest_roster_err = _prototype_e7m3_quest_roster_gate(quest_seen_ids) + if e7m3_quest_roster_err: + print(e7m3_quest_roster_err, file=sys.stderr) return 1 e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row) @@ -1996,6 +2330,24 @@ def main() -> int: print(e7m2_bundle_cross_ref_err, file=sys.stderr) return 1 + e7m3_faction_cross_ref_err = _prototype_e7m3_faction_cross_ref_gate( + quest_id_to_row, + faction_seen_ids, + ) + if e7m3_faction_cross_ref_err: + print(e7m3_faction_cross_ref_err, file=sys.stderr) + return 1 + + e7m3_bundle_freeze_err = _prototype_e7m3_completion_bundle_freeze_gate(quest_id_to_row) + if e7m3_bundle_freeze_err: + print(e7m3_bundle_freeze_err, file=sys.stderr) + return 1 + + e7m3_grid_contract_err = _prototype_e7m3_grid_contract_shape_gate(quest_id_to_row) + if e7m3_grid_contract_err: + print(e7m3_grid_contract_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), " @@ -2009,6 +2361,7 @@ def main() -> int: f"{len(npc_behavior_files)} npc behavior catalog file(s), " f"{len(reward_table_files)} reward table catalog file(s), " f"{len(encounter_files)} encounter catalog file(s), " + f"{len(faction_files)} faction catalog file(s), " f"{len(quest_files)} quest catalog file(s), " f"{len(seen_ids)} unique skill id(s), " f"{len(item_seen_ids)} unique item id(s), " @@ -2018,6 +2371,7 @@ def main() -> int: f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), " f"{len(reward_table_seen_ids)} unique reward table id(s), " f"{len(encounter_seen_ids)} unique encounter id(s), " + f"{len(faction_seen_ids)} unique faction id(s), " f"{len(quest_seen_ids)} unique quest id(s), " f"{len(track_skill_ids)} mastery track(s)" ) diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs index 78f953f..26f0911 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -35,4 +35,12 @@ internal static class QuestCatalogTestPaths internal static string DiscoverRepoQuestRewardBundleSchemaPath() => QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory()); + + internal static string DiscoverRepoFactionGateRuleSchemaPath() => + Path.GetFullPath( + Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "faction-gate-rule.schema.json")); + + internal static string DiscoverRepoReputationGrantRowSchemaPath() => + Path.GetFullPath( + Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json")); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index 37ead2a..50edc16 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -115,7 +115,10 @@ public class QuestDefinitionCatalogLoaderTests ], "completionRewardBundle": { "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], - "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] + "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }], + "reputationGrants": [ + { "factionId": "prototype_faction_grid_operators", "amount": 15 } + ] }, "steps": [ { @@ -167,6 +170,36 @@ public class QuestDefinitionCatalogLoaderTests ] } ] + }, + { + "id": "prototype_quest_grid_contract", + "displayName": "Grid Contract", + "prerequisiteQuestIds": ["prototype_quest_operator_chain"], + "factionGateRules": [ + { + "factionId": "prototype_faction_grid_operators", + "minStanding": 15 + } + ], + "completionRewardBundle": { + "reputationGrants": [ + { "factionId": "prototype_faction_rust_collective", "amount": 10 } + ] + }, + "steps": [ + { + "id": "grid_contract_step_kit", + "displayName": "Deliver survey drone kit", + "objectives": [ + { + "id": "grid_contract_obj_kit", + "kind": "inventory_has_item", + "itemId": "survey_drone_kit", + "quantity": 1 + } + ] + } + ] } ] } @@ -188,6 +221,8 @@ public class QuestDefinitionCatalogLoaderTests File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true); return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); } @@ -238,7 +273,7 @@ public class QuestDefinitionCatalogLoaderTests // Act var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); // Assert - Assert.Equal(4, catalog.DistinctQuestCount); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount); } [Fact] @@ -250,7 +285,7 @@ public class QuestDefinitionCatalogLoaderTests // Act var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); // Assert - Assert.Equal(4, catalog.DistinctQuestCount); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); Assert.NotNull(gather); @@ -734,7 +769,7 @@ public class QuestDefinitionCatalogLoaderTests // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var questCatalog = factory.Services.GetRequiredService(); - Assert.Equal(4, questCatalog.DistinctQuestCount); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, questCatalog.DistinctQuestCount); Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); Assert.NotNull(gather); Assert.Equal("Intro: Salvage Run", gather!.DisplayName); diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs index 671f6f6..b48685c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs @@ -260,7 +260,7 @@ public class QuestDefinitionRegistryTests Assert.Equal(GatherIntroId, questId); Assert.False(unknownNormalized); Assert.Equal("prototype_unknown", unknownQuestId); - Assert.Equal(4, list.Count); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, list.Count); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id)); foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds) { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs index bb1c333..40ee9ec 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs @@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Tests.Game.Quests; public class QuestDefinitionsWorldApiTests { [Fact] - public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder() + public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFrozenQuestsInIdOrder() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); @@ -21,7 +21,7 @@ public class QuestDefinitionsWorldApiTests Assert.NotNull(body); Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.NotNull(body.Quests); - Assert.Equal(4, body.Quests.Count); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, body.Quests.Count); Assert.Equal( PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(), body.Quests.Select(q => q.Id).ToArray()); diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs index 0c168e0..e0512b7 100644 --- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs @@ -17,12 +17,16 @@ public static class PrototypeE7M1QuestCatalogRules "prototype_quest_refine_intro", "prototype_quest_combat_intro", "prototype_quest_operator_chain", + "prototype_quest_grid_contract", ], StringComparer.Ordinal); /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_QUEST_ID. public const string ChainQuestId = "prototype_quest_operator_chain"; + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID. + public const string GridContractQuestId = "prototype_quest_grid_contract"; + /// First frozen onboarding quest id (gather intro). public const string GatherIntroQuestId = "prototype_quest_gather_intro"; diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs index 959425f..321638f 100644 --- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs @@ -30,6 +30,9 @@ public static class PrototypeE7M2QuestCatalogRules [PrototypeE7M1QuestCatalogRules.ChainQuestId] = new( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 50)]), + [PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new( + [], + []), }.ToFrozenDictionary(StringComparer.Ordinal); /// Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise . diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index 5d80031..1e456d1 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -451,6 +451,9 @@ public static class QuestDefinitionCatalogLoader if (_questDefSchema is not null) return; + var schemaDir = Path.GetDirectoryName(questDefSchemaPath)!; + CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "faction-gate-rule.schema.json")); + CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "reputation-grant-row.schema.json")); CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath); From 92c078b0e3e598d525d1ce200162c4fc41b6d03a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 19:51:42 -0400 Subject: [PATCH 3/5] NEO-133: Add code review for E7M3-01 catalog branch. --- docs/reviews/2026-06-15-NEO-133.md | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/reviews/2026-06-15-NEO-133.md diff --git a/docs/reviews/2026-06-15-NEO-133.md b/docs/reviews/2026-06-15-NEO-133.md new file mode 100644 index 0000000..1580719 --- /dev/null +++ b/docs/reviews/2026-06-15-NEO-133.md @@ -0,0 +1,61 @@ +# Code review — NEO-133 (E7M3-01) + +**Date:** 2026-06-15 +**Scope:** Branch `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci` — commits `7183461` … `0948d9d` vs `main` +**Base:** `main` (merge-base at `33cfc62`) + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands Epic 7 Slice 3 **content contracts** ahead of server faction load: three new JSON schemas (`FactionDef`, `FactionGateRule`, `ReputationGrantRow`), `prototype_factions.json`, quest JSON extensions (`factionGateRules`, `reputationGrants`), a fifth quest (`prototype_quest_grid_contract`), and matching CI gates in `scripts/validate_content.py`. Server changes are intentionally minimal—five-quest roster constants, rep-only grid-contract E7M2 bundle freeze entry, and schema `$ref` registration so startup validation accepts the new quest JSON. Documentation and decomposition tracking (E7.M3 backlog, module register, alignment table) are updated coherently. Risk is low: no runtime faction/rep behavior yet; `validate_content.py` and `dotnet test` both pass locally. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-133-implementation-plan.md` | **Matches** — shipped items reconcile with plan (schemas, catalog, CI, server parity, docs). | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-01 AC checked; client capstone NEO-142/143 documented. | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Slice 3 freeze table and CI line updated. | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Partially matches** — Slice 1 freeze table still documents **four** quests; CI enforcement paragraph still says “exactly these four quest ids” without noting E7M3 roster expansion (see Suggestions). | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M3 status In Progress, E7.M2 dependency added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row added. | +| `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 3 backlog link and Godot capstone AC. | +| `content/README.md` | **Matches** — factions folder, Slice 3 paragraph. | +| `server/README.md` | **N/A for NEO-133** — no faction section yet (planned NEO-134). | + +Register/tracking updates for E7.M3 are appropriately landed in this PR. + +## Blocking issues + +None. + +## Suggestions + +1. **E7.M1 module doc vs five-quest CI** — `E7_M1_QuestStateMachine.md` still states CI requires exactly the four Slice 1 quest ids. Add a one-line cross-link to E7.M3 (`PROTOTYPE_E7M3_QUEST_IDS`) so readers do not assume the four-quest gate is still the active roster check. + +2. **Server constant naming** — `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` and `TryGetE7M1GateError` now enforce **five** ids while labels/messages still say “E7M1”. When NEO-134 lands, consider `PrototypeE7M3QuestCatalogRules` (or rename + update error strings) to avoid onboarding confusion. + +3. **Remove dead CI helper** — `_prototype_e7m1_quest_gate` in `scripts/validate_content.py` is no longer called (superseded by `_prototype_e7m3_quest_roster_gate`). Delete or mark deprecated to prevent accidental reintroduction of the four-quest gate. + +4. **Faction standing band schema** — `faction-def.schema.json` does not constrain `minStanding <= neutralStanding <= maxStanding`. Optional JSON Schema `minimum`/`maximum` cross-field checks (or a CI gate mirroring faction freeze) would catch designer mistakes before NEO-134 server load. + +## Nits + +- Nit: `PROTOTYPE_E7M1_QUEST_IDS` (four ids) remains beside `PROTOTYPE_E7M3_QUEST_IDS` (five ids); fine for historical Slice 1 reference, but a short comment on E7M1 constant (“Slice 1 roster subset; CI roster gate is E7M3”) would clarify intent. +- Nit: `QuestDefinitionCatalogLoaderTests.Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract` asserts gather/chain rows but does not assert grid-contract gate/bundle fields; acceptable per plan (“no new Fact unless regression needs it”), but a single assert on grid-contract prereq/gate would cheaply lock the fifth quest shape server-side. + +## Verification + +Commands run during review (passed): + +```bash +python3 scripts/validate_content.py +dotnet test server/NeonSprawl.Server.Tests +``` + +Author should confirm PR CI content gate green before merge. + +**Known intentional gap (not a blocker for E7M3-01):** Server `QuestDefRow` / `QuestRewardBundleRow` do not model `factionGateRules` or `reputationGrants`; JSON is schema-validated at load but ignored until NEO-134–NEO-138. Grid contract can be accepted without standing check until NEO-137/NEO-138. From 6cb7dfbb46da66cae9449d057dbd9def9aaec578 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 19:52:42 -0400 Subject: [PATCH 4/5] =?UTF-8?q?NEO-133:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20roster=20docs,=20CI=20cleanup,=20band=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-link E7.M1 doc to E7M3 five-quest roster; remove dead _prototype_e7m1_quest_gate; add faction standing-band CI gate; clarify server roster error strings; grid-contract loader test asserts. --- .../modules/E7_M1_QuestStateMachine.md | 2 +- docs/reviews/2026-06-15-NEO-133.md | 14 +++---- scripts/validate_content.py | 39 ++++++++++++------- .../QuestDefinitionCatalogLoaderTests.cs | 10 ++++- .../Quests/PrototypeE7M1QuestCatalogRules.cs | 13 ++++--- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index c931843..9e604f1 100644 --- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md +++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md @@ -76,7 +76,7 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del | **`prototype_quest_combat_intro`** | Intro: Clear the Pocket | *(none)* | 1× **`encounter_complete`**: **`prototype_combat_pocket`** | | **`prototype_quest_operator_chain`** | Operator Chain | **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`** | 4 steps: gather **`scrap_metal_bulk` ×5** → craft **`refine_scrap_standard`** → craft **`make_field_stim_mk0`** → **`inventory_has_item`** **`contract_handoff_token` ×1** | -**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). +**CI enforcement (NEO-112):** `scripts/validate_content.py` still enforces the **four Slice 1 quest ids** in the freeze table above (prerequisite graph, objective cross-refs, chain terminal step). **Roster allowlist** expanded to **five** quests under [E7.M3](E7_M3_FactionReputationLedger.md) (`PROTOTYPE_E7M3_QUEST_IDS`, NEO-133) — adds **`prototype_quest_grid_contract`**. Shared gates: objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; operator chain terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). **Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). diff --git a/docs/reviews/2026-06-15-NEO-133.md b/docs/reviews/2026-06-15-NEO-133.md index 1580719..1f53d9a 100644 --- a/docs/reviews/2026-06-15-NEO-133.md +++ b/docs/reviews/2026-06-15-NEO-133.md @@ -19,7 +19,7 @@ This branch lands Epic 7 Slice 3 **content contracts** ahead of server faction l | `docs/plans/NEO-133-implementation-plan.md` | **Matches** — shipped items reconcile with plan (schemas, catalog, CI, server parity, docs). | | `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-01 AC checked; client capstone NEO-142/143 documented. | | `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Slice 3 freeze table and CI line updated. | -| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Partially matches** — Slice 1 freeze table still documents **four** quests; CI enforcement paragraph still says “exactly these four quest ids” without noting E7M3 roster expansion (see Suggestions). | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — Slice 1 freeze table preserved; CI paragraph cross-links E7.M3 five-quest roster. | | `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M3 status In Progress, E7.M2 dependency added. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row added. | | `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 3 backlog link and Godot capstone AC. | @@ -34,18 +34,18 @@ None. ## Suggestions -1. **E7.M1 module doc vs five-quest CI** — `E7_M1_QuestStateMachine.md` still states CI requires exactly the four Slice 1 quest ids. Add a one-line cross-link to E7.M3 (`PROTOTYPE_E7M3_QUEST_IDS`) so readers do not assume the four-quest gate is still the active roster check. +1. ~~**E7.M1 module doc vs five-quest CI** — `E7_M1_QuestStateMachine.md` still states CI requires exactly the four Slice 1 quest ids. Add a one-line cross-link to E7.M3 (`PROTOTYPE_E7M3_QUEST_IDS`) so readers do not assume the four-quest gate is still the active roster check.~~ **Done.** CI paragraph cross-links E7.M3 roster expansion. -2. **Server constant naming** — `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` and `TryGetE7M1GateError` now enforce **five** ids while labels/messages still say “E7M1”. When NEO-134 lands, consider `PrototypeE7M3QuestCatalogRules` (or rename + update error strings) to avoid onboarding confusion. +2. ~~**Server constant naming** — `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` and `TryGetE7M1GateError` now enforce **five** ids while labels/messages still say “E7M1”. When NEO-134 lands, consider `PrototypeE7M3QuestCatalogRules` (or rename + update error strings) to avoid onboarding confusion.~~ **Done (partial).** Error string now says “prototype quest roster”; class/XML docs note NEO-134 rename. Full type rename deferred to NEO-134. -3. **Remove dead CI helper** — `_prototype_e7m1_quest_gate` in `scripts/validate_content.py` is no longer called (superseded by `_prototype_e7m3_quest_roster_gate`). Delete or mark deprecated to prevent accidental reintroduction of the four-quest gate. +3. ~~**Remove dead CI helper** — `_prototype_e7m1_quest_gate` in `scripts/validate_content.py` is no longer called (superseded by `_prototype_e7m3_quest_roster_gate`). Delete or mark deprecated to prevent accidental reintroduction of the four-quest gate.~~ **Done.** Dead helper removed. -4. **Faction standing band schema** — `faction-def.schema.json` does not constrain `minStanding <= neutralStanding <= maxStanding`. Optional JSON Schema `minimum`/`maximum` cross-field checks (or a CI gate mirroring faction freeze) would catch designer mistakes before NEO-134 server load. +4. ~~**Faction standing band schema** — `faction-def.schema.json` does not constrain `minStanding <= neutralStanding <= maxStanding`. Optional JSON Schema `minimum`/`maximum` cross-field checks (or a CI gate mirroring faction freeze) would catch designer mistakes before NEO-134 server load.~~ **Done.** `_prototype_e7m3_faction_standing_band_gate` enforces ordering on all faction rows. ## Nits -- Nit: `PROTOTYPE_E7M1_QUEST_IDS` (four ids) remains beside `PROTOTYPE_E7M3_QUEST_IDS` (five ids); fine for historical Slice 1 reference, but a short comment on E7M1 constant (“Slice 1 roster subset; CI roster gate is E7M3”) would clarify intent. -- Nit: `QuestDefinitionCatalogLoaderTests.Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract` asserts gather/chain rows but does not assert grid-contract gate/bundle fields; acceptable per plan (“no new Fact unless regression needs it”), but a single assert on grid-contract prereq/gate would cheaply lock the fifth quest shape server-side. +- ~~Nit: `PROTOTYPE_E7M1_QUEST_IDS` (four ids) remains beside `PROTOTYPE_E7M3_QUEST_IDS` (five ids); fine for historical Slice 1 reference, but a short comment on E7M1 constant (“Slice 1 roster subset; CI roster gate is E7M3”) would clarify intent.~~ **Done.** +- ~~Nit: `QuestDefinitionCatalogLoaderTests.Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract` asserts gather/chain rows but does not assert grid-contract gate/bundle fields; acceptable per plan (“no new Fact unless regression needs it”), but a single assert on grid-contract prereq/gate would cheaply lock the fifth quest shape server-side.~~ **Done.** Asserts grid-contract prereq and rep-only bundle (item/skill empty at server parse layer). ## Verification diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 7d67034..2e48a60 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -166,8 +166,9 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = { "contract_handoff_token": 1, } -# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes. -# Keep in sync with E7.M1 freeze table and future NEO-113 server loader. +# Epic 7 Slice 1 prototype lock (NEO-112): Slice 1 onboarding quest ids (historical subset). +# Active CI roster gate is PROTOTYPE_E7M3_QUEST_IDS (five quests, NEO-133). +# Keep in sync with E7.M1 freeze table; server ExpectedQuestIds expanded for E7M3. PROTOTYPE_E7M1_QUEST_IDS = frozenset( { "prototype_quest_gather_intro", @@ -1153,17 +1154,6 @@ def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None: return None -def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None: - """Return a human-readable error if E7M1 quest contract fails, else None.""" - ids = frozenset(seen_ids.keys()) - if ids != PROTOTYPE_E7M1_QUEST_IDS: - return ( - "error: prototype E7M1 expects exactly quest ids " - f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}" - ) - return None - - def _prototype_e7m1_quest_prerequisite_gate(id_to_row: dict[str, dict]) -> str | None: """Return a human-readable error if prerequisite ids are unknown or cyclic, else None.""" known = frozenset(id_to_row.keys()) @@ -1387,6 +1377,24 @@ def _prototype_e7m3_faction_freeze_gate(id_to_row: dict[str, dict]) -> str | Non return None +def _prototype_e7m3_faction_standing_band_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if faction standing band ordering fails, else None.""" + for fid, row in id_to_row.items(): + if not isinstance(row, dict): + continue + min_standing = row.get("minStanding") + max_standing = row.get("maxStanding") + neutral_standing = row.get("neutralStanding") + if not all(isinstance(value, int) for value in (min_standing, max_standing, neutral_standing)): + continue + if min_standing > neutral_standing or neutral_standing > max_standing: + return ( + f"error: faction {fid!r} requires minStanding <= neutralStanding <= maxStanding " + f"(got min={min_standing!r}, neutral={neutral_standing!r}, max={max_standing!r})" + ) + return None + + def _prototype_e7m3_faction_cross_ref_gate( id_to_row: dict[str, dict], faction_seen_ids: dict[str, str], @@ -2284,6 +2292,11 @@ def main() -> int: print(e7m3_faction_freeze_err, file=sys.stderr) return 1 + e7m3_faction_band_err = _prototype_e7m3_faction_standing_band_gate(faction_id_to_row) + if e7m3_faction_band_err: + print(e7m3_faction_band_err, file=sys.stderr) + return 1 + quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs( quest_files=quest_files, quest_validator=quest_validator, diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index 50edc16..8dc20c6 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -302,6 +302,14 @@ public class QuestDefinitionCatalogLoaderTests Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants); Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId); Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount); + Assert.True(catalog.TryGetQuest(PrototypeE7M1QuestCatalogRules.GridContractQuestId, out var gridContract)); + Assert.NotNull(gridContract); + Assert.Equal( + [PrototypeE7M1QuestCatalogRules.ChainQuestId], + gridContract!.PrerequisiteQuestIds); + Assert.NotNull(gridContract.CompletionRewardBundle); + Assert.Empty(gridContract.CompletionRewardBundle!.ItemGrants); + Assert.Empty(gridContract.CompletionRewardBundle.SkillXpGrants); } [Fact] @@ -471,7 +479,7 @@ public class QuestDefinitionCatalogLoaderTests var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); // Assert var ioe = Assert.IsType(ex); - Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype quest roster expects exactly quest ids", ioe.Message, StringComparison.Ordinal); } [Fact] diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs index e0512b7..5b93314 100644 --- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs @@ -3,14 +3,15 @@ using System.Collections.Frozen; namespace NeonSprawl.Server.Game.Quests; /// -/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from -/// scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS, +/// Prototype quest roster + cross-ref gates (NEO-113, NEO-133), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M3_QUEST_IDS (five ids), /// PROTOTYPE_E7M1_CHAIN_QUEST_ID, PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID, -/// and _prototype_e7m1_* gate functions. +/// and _prototype_e7m1_* / _prototype_e7m3_* gate functions. +/// Class name retained for NEO-113 call sites; rename to PrototypeE7M3QuestCatalogRules planned in NEO-134. /// public static class PrototypeE7M1QuestCatalogRules { - /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS. + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_QUEST_IDS (five quests). public static readonly FrozenSet ExpectedQuestIds = FrozenSet.ToFrozenSet( [ "prototype_quest_gather_intro", @@ -45,14 +46,14 @@ public static class PrototypeE7M1QuestCatalogRules /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID. public const string ChainTerminalItemId = "contract_handoff_token"; - /// Returns a human-readable error if the E7M1 quest contract fails, otherwise . + /// Returns a human-readable error if the prototype quest roster fails, otherwise . public static string? TryGetE7M1GateError(IReadOnlyDictionary questIdToSourceFile) { var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); if (!ids.SetEquals(ExpectedQuestIds)) { return - "error: prototype E7M1 expects exactly quest ids " + + "error: prototype quest roster expects exactly quest ids " + $"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; } From 635f3cccafe403ffb5254edb839d01c0cd5fd0a2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 19:58:37 -0400 Subject: [PATCH 5/5] NEO-133: fix Bruno quest smokes for five-quest roster Update quest-definitions and quest-progress default Bruno tests after prototype_quest_grid_contract landed in the catalog. --- .../quest-definitions/Get quest definitions.bru | 17 ++++++++++++++++- .../Get quest progress default.bru | 5 +++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru index abfc4c8..676509e 100644 --- a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru +++ b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru @@ -17,7 +17,7 @@ tests { const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.quests).to.be.an("array"); - expect(body.quests.length).to.equal(4); + expect(body.quests.length).to.equal(5); }); test("quests are ascending by id (ordinal)", function () { @@ -54,4 +54,19 @@ tests { quantity: 1, }); }); + + test("prototype_quest_grid_contract has operator-chain prereq and kit hand-in objective", function () { + const body = res.getBody(); + const row = body.quests.find((x) => x.id === "prototype_quest_grid_contract"); + expect(row).to.be.an("object"); + expect(row.displayName).to.equal("Grid Contract"); + expect(row.prerequisiteQuestIds).to.eql(["prototype_quest_operator_chain"]); + expect(row.steps).to.have.length(1); + expect(row.steps[0].objectives[0]).to.eql({ + id: "grid_contract_obj_kit", + kind: "inventory_has_item", + itemId: "survey_drone_kit", + quantity: 1, + }); + }); } diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru index 1f17c1d..8c3a811 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru @@ -5,7 +5,7 @@ meta { } docs { - NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started. + NEO-119: fresh dev-local-1 row before any quest accept — all five catalog quests not_started. } get { @@ -21,7 +21,7 @@ tests { expect(body.schemaVersion).to.equal(1); expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); expect(body.quests).to.be.an("array"); - expect(body.quests.length).to.equal(4); + expect(body.quests.length).to.equal(5); }); test("all prototype quests are not_started with empty counters", function () { @@ -29,6 +29,7 @@ tests { const expectedIds = [ "prototype_quest_combat_intro", "prototype_quest_gather_intro", + "prototype_quest_grid_contract", "prototype_quest_operator_chain", "prototype_quest_refine_intro", ];