diff --git a/bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru b/bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru new file mode 100644 index 0000000..957fbf4 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (faction catalog boot NEO-134) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-134 loads content/factions/*_factions.json at startup (fail-fast). No faction HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/faction-catalog/folder.bru b/bruno/neon-sprawl-server/faction-catalog/folder.bru new file mode 100644 index 0000000..c7f1762 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: faction-catalog +} diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 087f9de..dd2a4f0 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Pre-production | -| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)): 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**. | +| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-03** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index b89c922..6fd45f7 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,7 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | -| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions (`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 | +| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03+** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | --- diff --git a/docs/plans/NEO-134-implementation-plan.md b/docs/plans/NEO-134-implementation-plan.md new file mode 100644 index 0000000..370f8d0 --- /dev/null +++ b/docs/plans/NEO-134-implementation-plan.md @@ -0,0 +1,207 @@ +# NEO-134 — E7M3-02: Server faction catalog load (fail-fast) + +**Linear:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) +**Branch:** `NEO-134-e7m3-server-faction-catalog-load-fail-fast` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-02** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-125-implementation-plan.md](NEO-125-implementation-plan.md) (catalog loader + CI-parity gates); [NEO-113](NEO-113-implementation-plan.md) (skill/item catalog DI shape) +**Precursor:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) **`Done`** — faction schemas, `prototype_factions.json`, quest `factionGateRules` / `reputationGrants`, five-quest CI gates (**landed on `main`**) + +## Goal + +Fail-fast startup load of `content/factions/*_factions.json` with **CI-parity** validation; cross-check quest **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against the loaded faction catalog. Host refuses to listen when faction content or quest faction refs are invalid. + +## Kickoff clarifications + +**No clarifications needed.** Linear AC, [E7M3-02 backlog scope](E7M3-pre-production-backlog.md#e7m3-02--server-faction-catalog-load-fail-fast), kickoff decisions table, and NEO-125/NEO-133 precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Module folder | `server/NeonSprawl.Server/Game/Factions/` | Backlog lists this path first; contracts are `FactionDef` / `IFactionDefinitionRegistry` | +| CI parity | Full E7M3 faction + quest faction gates on server | Backlog “CI-parity validation”; NEO-125 full E7M2 gate precedent | +| Quest DTO parse | Parse `factionGateRules` + `reputationGrants` onto typed rows now | NEO-125 parsed `completionRewardBundle` at load; NEO-137 gate eval needs rows | +| Load order | Faction catalog **before** quest catalog | Quest cross-ref gate needs `knownFactionIds` from faction registry | +| E7M2 bundle freeze | Keep existing item/skill freeze; **add** E7M3 freeze including rep | CI runs both `_prototype_e7m2_*` and `_prototype_e7m3_completion_bundle_freeze_gate` | +| `PrototypeE7M1QuestCatalogRules` rename | **Defer** full type rename | NEO-133 partial fix (error strings); rename is churn across many call sites — not in E7M3-02 AC | +| Standing store / apply / HTTP / Godot | **Out of scope** | Backlog E7M3-02; NEO-135+ | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- Faction catalog loader, catalog type, `IFactionDefinitionRegistry`, DI registration. +- `PrototypeE7M3FactionCatalogRules` — roster, freeze table, standing-band ordering (mirrors `scripts/validate_content.py` `PROTOTYPE_E7M3_FACTION_*` + `_prototype_e7m3_faction_*` gates). +- Quest loader extensions: parse `FactionGateRuleRow`, `ReputationGrantRow`; E7M3 quest gates — faction cross-ref, E7M3 completion bundle freeze (item + skill + rep), grid-contract shape. +- `ContentPathsOptions` + path resolution for `content/factions/`. +- Unit tests (AAA) for faction loader happy path + broken refs; quest loader faction cross-ref negatives; host boot resolves faction + quest catalogs. +- `server/README.md` faction catalog section. + +**Out of scope:** + +- Per-player standing store, reputation apply, gate evaluation on accept, HTTP projection (NEO-135–NEO-139). +- Godot client (**client counterpart:** none — infrastructure-only). + +## Acceptance criteria checklist + +- [x] Host exits on invalid faction catalog or broken quest faction refs at startup. +- [x] Registry resolves faction metadata by id. +- [x] `dotnet test` covers loader gates. + +## Implementation reconciliation (shipped) + +- **Faction catalog:** `Game/Factions/` loader, catalog, `IFactionDefinitionRegistry`, `PrototypeE7M3FactionCatalogRules`, DI + `Program.cs` load order (after skills, before quests). +- **Quest extensions:** `FactionGateRuleRow`, `ReputationGrantRow`, `PrototypeE7M3QuestFactionRules` (cross-ref, E7M3 bundle freeze, grid-contract shape); quest loader parses faction fields and runs E7M3 gates. +- **Tests:** `FactionDefinitionCatalogLoaderTests`, `FactionDefinitionRegistryTests`, four new quest-loader negative cases; host boot resolves faction + quest catalogs (`746` tests green). +- **Docs:** `server/README.md` faction catalog section; quest catalog paragraph updated for E7M3 gates. + +## Technical approach + +### 1. Faction DTOs and catalog (`Game/Factions/`) + +| Type | Role | +|------|------| +| `FactionDefRow` | `Id`, `DisplayName`, `MinStanding`, `MaxStanding`, `NeutralStanding` | +| `FactionGateRuleRow` | `FactionId`, `MinStanding` (quest-side; lives in `Game/Quests/` or shared — prefer `Game/Quests/` next to quest rows) | +| `ReputationGrantRow` | `FactionId`, `Amount` (quest bundle row; `Game/Quests/`) | +| `FactionDefinitionCatalog` | Immutable `ById` map + directory metadata (mirror `SkillDefinitionCatalog`) | +| `IFactionDefinitionRegistry` / `FactionDefinitionRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror item registry) | + +### 2. `FactionDefinitionCatalogLoader` + +Mirror `SkillDefinitionCatalogLoader`: + +- Enumerate `*_factions.json` under factions directory; require `schemaVersion` 1 and top-level `factions` array. +- JSON Schema validate each row against `faction-def.schema.json`. +- Reject duplicate faction ids across files. +- Run `PrototypeE7M3FactionCatalogRules`: roster (exactly two ids), freeze table (display names + standing band), standing-band ordering (`minStanding <= neutralStanding <= maxStanding`). +- Throw `InvalidOperationException` with sorted `error:` lines on failure. + +### 3. `PrototypeE7M3FactionCatalogRules` + +Sync to `scripts/validate_content.py`: + +| Constant / helper | Python source | +|-------------------|---------------| +| `ExpectedFactionIds` | `PROTOTYPE_E7M3_FACTION_IDS` | +| `ExpectedFactionFreeze` | `PROTOTYPE_E7M3_FACTION_FREEZE` | +| `TryGetRosterGateError` | `_prototype_e7m3_faction_roster_gate` | +| `TryGetFreezeGateError` | `_prototype_e7m3_faction_freeze_gate` | +| `TryGetStandingBandGateError` | `_prototype_e7m3_faction_standing_band_gate` | + +### 4. `PrototypeE7M3QuestFactionRules` (`Game/Quests/`) + +Sync quest-side E7M3 gates: + +| Helper | Python source | +|--------|---------------| +| `TryGetFactionCrossRefError(rows, knownFactionIds)` | `_prototype_e7m3_faction_cross_ref_gate` | +| `TryGetCompletionBundleFreezeError(rows)` | `_prototype_e7m3_completion_bundle_freeze_gate` (uses `PROTOTYPE_E7M3_COMPLETION_BUNDLES` incl. rep) | +| `TryGetGridContractShapeError(rows)` | `_prototype_e7m3_grid_contract_shape_gate` | + +Extend **`QuestDefRow`** with optional `FactionGateRules`. Extend **`QuestRewardBundleRow`** with `ReputationGrants`. Update **`ParseRow`** / **`ParseCompletionRewardBundle`** in `QuestDefinitionCatalogLoader`. + +Run E7M3 quest gates **after** E7M2 bundle gates, passing `knownFactionIds` from faction catalog into cross-ref. + +### 5. Path resolution and DI + +- Add `FactionsDirectory`, `FactionDefSchemaPath` to `ContentPathsOptions`. +- `FactionCatalogPathResolution` — discover `content/factions`, resolve `faction-def.schema.json` sibling under `content/schemas/`. +- `FactionCatalogServiceCollectionExtensions.AddFactionDefinitionCatalog` — singleton catalog + registry. +- **`Program.cs`:** register faction catalog **after** skill catalog, **before** quest catalog; eager-resolve `FactionDefinitionCatalog` at boot. +- **`QuestCatalogServiceCollectionExtensions`:** inject `FactionDefinitionCatalog`; pass `factionCatalog.ById.Keys` into quest loader. + +### 6. Tests + +**Faction loader (`FactionDefinitionCatalogLoaderTests`):** + +- Happy path — repo `prototype_factions.json`. +- Missing factions directory / schema. +- Duplicate faction id. +- Wrong roster count or unknown id. +- Freeze table mismatch (display name or standing). +- Invalid standing band ordering. + +**Quest loader extensions (`QuestDefinitionCatalogLoaderTests`):** + +- Unknown `factionId` in `factionGateRules`. +- Unknown `factionId` in `reputationGrants`. +- E7M3 bundle freeze mismatch (operator-chain rep row). +- Grid-contract shape violation (wrong `minStanding` or objective). +- Existing repo prototype load + `Host_ShouldResolveQuestCatalogFromDi` still pass. + +**Registry (`FactionDefinitionRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing. + +### 7. Docs + +- **`server/README.md`** — new faction catalog paragraph (config keys, gates, load order before quests). +- Optional one-line E7_M3 module status when implementation lands. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs` | Load-time faction row | +| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs` | In-memory catalog | +| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs` | Fail-fast disk load | +| `server/NeonSprawl.Server/Game/Factions/FactionCatalogPathResolution.cs` | Directory + schema path discovery | +| `server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs` | DI registration | +| `server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs` | Lookup contract | +| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs` | Registry adapter | +| `server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs` | CI-parity faction gates | +| `server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs` | Quest gate row | +| `server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs` | Bundle rep grant row | +| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs` | Quest-side E7M3 faction gates | +| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs` | Loader AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs` | Registry AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs` | Repo path discovery for tests | + +## Files to modify + +| Path | Change | +|------|--------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `FactionsDirectory`, `FactionDefSchemaPath` | +| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Optional `FactionGateRules` property | +| `server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs` | Add `ReputationGrants` list | +| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Parse faction fields; accept `knownFactionIds`; run E7M3 quest faction gates | +| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Inject faction catalog into quest loader | +| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve faction catalog before quests | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | E7M3 negative cases; extend `LoadCatalog` helper with faction ids | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Faction directory/schema discovery helpers | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Ensure host boot resolves faction catalog if needed | +| `server/README.md` | Faction catalog section | + +## Tests + +| Layer | What | +|-------|------| +| `FactionDefinitionCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/standing-band gates | +| `FactionDefinitionRegistryTests` | Lookup by id, ordered listing | +| `QuestDefinitionCatalogLoaderTests` | Faction cross-ref, E7M3 bundle freeze, grid-contract shape; repo prototype + host boot | +| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) | + +Manual QA: **none** (server infrastructure; Godot capstone is NEO-143). + +## Implementation order + +1. Faction DTOs, rules, path resolution, loader, catalog, registry, DI. +2. Quest DTO extensions + parse + `PrototypeE7M3QuestFactionRules` + wire into quest loader. +3. `Program.cs` load order + `ContentPathsOptions`. +4. Tests (faction loader → quest negatives → host boot). +5. `server/README.md`. +6. Run `dotnet test` on server project. + +## Open questions / risks + +| Item | Agent recommendation | Status | +|------|---------------------|--------| +| Rename `PrototypeE7M1QuestCatalogRules` → `PrototypeE7M3QuestCatalogRules` | Defer — not required for AC; large rename across tests and call sites | `deferred` | +| E7M2 vs E7M3 bundle freeze overlap | Keep both gates (CI does); E7M2 item/skill only, E7M3 adds rep rows | `adopted` | +| Quest loader already registers `faction-gate-rule` / `reputation-grant-row` schemas (NEO-133) | Reuse; no schema registration changes needed | `adopted` | + +## Client counterpart + +None for E7M3-02. Client faction HUD: **NEO-142** / capstone **NEO-143**. + +## Blocks + +**NEO-135** (faction standing store) is blocked until faction catalog load lands. diff --git a/docs/reviews/2026-06-15-NEO-134.md b/docs/reviews/2026-06-15-NEO-134.md new file mode 100644 index 0000000..91ba3f0 --- /dev/null +++ b/docs/reviews/2026-06-15-NEO-134.md @@ -0,0 +1,55 @@ +# Code review — NEO-134 (E7M3-02) + +**Date:** 2026-06-15 +**Scope:** Branch `NEO-134-e7m3-server-faction-catalog-load-fail-fast` — commits `edb3351` … `48bca98` vs `main` +**Base:** `main` + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands **E7M3-02**: fail-fast startup load of `content/factions/*_factions.json` with CI-parity roster/freeze/standing-band gates, `IFactionDefinitionRegistry`, and quest-loader extensions that parse `factionGateRules` / `reputationGrants` and run E7M3 cross-ref, completion-bundle freeze (including rep rows), and grid-contract shape gates. Load order is correct (skills → factions → quests via DI dependency). The implementation mirrors established catalog patterns (`SkillDefinitionCatalogLoader`, NEO-125 quest gates) and keeps Python/C# gate constants in sync. Test coverage is strong (faction loader negatives, registry + host boot, four new quest-loader negatives); `746` server tests and `validate_content.py` pass locally. Risk is low — infrastructure-only, no player-facing runtime behavior yet. Minor gaps: decomposition tracking docs not updated for E7M3-02, and two plan-listed loader negative cases are only partially covered. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-134-implementation-plan.md` | **Matches** — shipped items reconcile with plan (faction catalog, quest extensions, DI/load order, tests, README). AC checklist marked complete. | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-02 scope (server load, CI parity, no client counterpart) aligns with implementation. | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-02 server load landed (NEO-134). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-02 / NEO-134. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register update required for this slice. | +| `server/README.md` | **Matches** — new faction catalog section; quest catalog paragraph updated for E7M3 gates and faction dependency. | +| `content/README.md` | **N/A** — no content changes in this branch. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-02 server faction catalog load** landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)). Plan §7 lists this as optional but it keeps module tracking honest for NEO-135 consumers.~~ **Done.** Module status + alignment table updated for E7M3-02 / NEO-134. + +2. ~~**Standing-band loader integration test** — Plan §6 lists “Invalid standing band ordering” under `FactionDefinitionCatalogLoaderTests`. `TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails` covers the rule in isolation; add a `Load_ShouldThrow_WhenStandingBandOrderingFails` that writes invalid `minStanding`/`neutralStanding` JSON through the full loader path (cheap regression lock).~~ **Done.** Added `Load_ShouldThrow_WhenStandingBandOrderingFails`; loader runs standing-band gate before freeze so the integration path reports the band error. + +3. ~~**Missing factions directory test** — Plan §6 also lists “Missing factions directory / schema.” Schema-missing is covered; add `Load_ShouldThrow_WhenFactionsDirectoryMissing` for parity with other catalog loader test suites.~~ **Done.** + +## Nits + +- ~~Nit: `FactionDefinitionCatalogLoaderTests.cs` imports `Microsoft.Extensions.DependencyInjection` but does not use it.~~ **Done.** +- ~~Nit: `FactionDefinitionCatalogLoader` logs unconditionally on success while `QuestDefinitionCatalogLoader` guards with `logger.IsEnabled(LogLevel.Information)` — harmless, but inconsistent with sibling loaders.~~ **Done.** + +## Verification + +Commands run during review (passed): + +```bash +dotnet test server/NeonSprawl.Server.Tests +python3 scripts/validate_content.py +``` + +Author should confirm PR CI green before merge. Bruno smoke (`bruno/neon-sprawl-server/faction-catalog/`) is appropriate — no manual QA doc required per plan (infrastructure-only). + +**Intentional follow-on (not blockers):** Standing store, gate evaluation on accept, HTTP projection, and Godot HUD remain NEO-135–NEO-143. `PrototypeE7M1QuestCatalogRules` full rename remains deferred per plan. diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs new file mode 100644 index 0000000..e5bdc71 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Factions; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +internal static class FactionCatalogTestPaths +{ + internal static string DiscoverRepoFactionsDirectory() => + FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoFactionDefSchemaPath() => + FactionCatalogPathResolution.ResolveFactionDefSchemaPath( + DiscoverRepoFactionsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..edc24d7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs @@ -0,0 +1,217 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Factions; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public class FactionDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "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 + } + ] + } + """; + + private static (string Root, string FactionsDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-factioncat-"); + var factionsDir = Path.Combine(root.FullName, "content", "factions"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(factionsDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "faction-def.schema.json"); + File.Copy(FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, factionsDir, schemaPath); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile() + { + // Arrange + var factionsDir = FactionCatalogTestPaths.DiscoverRepoFactionsDirectory(); + var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(); + // Act + var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance); + // Assert + Assert.Equal(PrototypeE7M3FactionCatalogRules.ExpectedFactionIds.Count, catalog.DistinctFactionCount); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + // Act + var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance); + // Assert + Assert.Equal(2, catalog.DistinctFactionCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.ById.TryGetValue("prototype_faction_grid_operators", out var grid)); + Assert.NotNull(grid); + Assert.Equal("Grid Operators", grid!.DisplayName); + Assert.Equal(0, grid.NeutralStanding); + } + + [Fact] + public void Load_ShouldThrow_WhenFactionsIsNotArray() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(factionsDir, "bad_factions.json"), """{"schemaVersion": 1, "factions": "nope"}""", Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_factions.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'factions' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var singleFaction = """ + { + "schemaVersion": 1, + "factions": [ + { + "id": "prototype_faction_grid_operators", + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + } + ] + } + """; + File.WriteAllText(Path.Combine(factionsDir, "a_factions.json"), singleFaction, Encoding.UTF8); + File.WriteAllText(Path.Combine(factionsDir, "b_factions.json"), singleFaction, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate faction id", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRosterGateFails() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var oneFaction = """ + { + "schemaVersion": 1, + "factions": [ + { + "id": "prototype_faction_grid_operators", + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + } + ] + } + """; + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), oneFaction, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E7M3 expects exactly faction ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenFreezeTableDiverges() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var badDisplay = ValidPrototypeCatalogJson.Replace("Grid Operators", "Wrong Name", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badDisplay, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + 10, + 100, + 0), + }; + // Act + var error = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows); + // Assert + Assert.NotNull(error); + Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", error, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenStandingBandOrderingFails() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var badBand = ValidPrototypeCatalogJson.Replace("\"minStanding\": -100", "\"minStanding\": 10", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badBand, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenFactionsDirectoryMissing() + { + // Arrange + var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-factions-" + Guid.NewGuid().ToString("n")); + var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaFileMissing() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + File.Delete(schemaPath); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs new file mode 100644 index 0000000..91b1a55 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs @@ -0,0 +1,126 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public class FactionDefinitionRegistryTests +{ + private static FactionDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new FactionDefinitionRegistry(catalog); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrue_WhenIdExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Grid Operators", def!.DisplayName); + Assert.Equal(-100, def.MinStanding); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenFactionIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_faction", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleFactions() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_rust_collective"] = new( + "prototype_faction_rust_collective", + "Rust Collective", + -100, + 100, + 0), + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(2, list.Count); + Assert.Equal("prototype_faction_grid_operators", list[0].Id); + Assert.Equal("prototype_faction_rust_collective", list[1].Id); + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var registry = factory.Services.GetRequiredService(); + var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var grid); + // Assert + Assert.True(found); + Assert.NotNull(grid); + Assert.Equal("Grid Operators", grid!.DisplayName); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs index 26f0911..96867ae 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -43,4 +43,7 @@ internal static class QuestCatalogTestPaths internal static string DiscoverRepoReputationGrantRowSchemaPath() => Path.GetFullPath( Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json")); + + internal static string DiscoverRepoFactionsDirectory() => + Path.GetFullPath(Path.Combine(DiscoverRepoQuestsDirectory(), "..", "factions")); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index 8dc20c6..79ee3cc 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Skills; @@ -18,6 +19,8 @@ namespace NeonSprawl.Server.Tests.Game.Quests; public class QuestDefinitionCatalogLoaderTests { + private static readonly FrozenSet KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds; + private static readonly FrozenSet KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; private static readonly FrozenSet KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds; @@ -247,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests string defSchemaPath, string stepSchemaPath, string objectiveSchemaPath, - IReadOnlyDictionary? skillDefsById = null) => + IReadOnlyDictionary? skillDefsById = null, + IReadOnlySet? knownFactionIds = null) => QuestDefinitionCatalogLoader.Load( questsDir, defSchemaPath, @@ -259,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests KnownItemIds, KnownRecipeIds, KnownEncounterIds, + knownFactionIds ?? KnownFactionIds, skillDefsById ?? RepoSkillDefs, NullLogger.Instance); @@ -752,6 +757,88 @@ public class QuestDefinitionCatalogLoaderTests Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); } + [Fact] + public void Load_ShouldThrow_WhenFactionGateReferencesUnknownFaction() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var grid = GetQuestRow(root, "prototype_quest_grid_contract"); + var gateRules = grid["factionGateRules"] as JsonArray + ?? throw new InvalidOperationException("expected factionGateRules"); + (gateRules[0] as JsonObject)!["factionId"] = "unknown_faction_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("factionGateRules[0]", ioe.Message, StringComparison.Ordinal); + Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenReputationGrantReferencesUnknownFaction() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var bundle = chain["completionRewardBundle"] as JsonObject + ?? throw new InvalidOperationException("expected completionRewardBundle"); + var grants = bundle["reputationGrants"] as JsonArray + ?? throw new InvalidOperationException("expected reputationGrants"); + (grants[0] as JsonObject)!["factionId"] = "unknown_faction_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("reputationGrants[0]", ioe.Message, StringComparison.Ordinal); + Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE7M3CompletionBundleFreezeDiverges() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var bundle = chain["completionRewardBundle"] as JsonObject + ?? throw new InvalidOperationException("expected completionRewardBundle"); + var grants = bundle["reputationGrants"] as JsonArray + ?? throw new InvalidOperationException("expected reputationGrants"); + (grants[0] as JsonObject)!["amount"] = 99; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("completionRewardBundle must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenGridContractGateMinStandingDiverges() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var grid = GetQuestRow(root, "prototype_quest_grid_contract"); + var gateRules = grid["factionGateRules"] as JsonArray + ?? throw new InvalidOperationException("expected factionGateRules"); + (gateRules[0] as JsonObject)!["minStanding"] = 10; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("factionGateRules must be", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenMissingSchemaFile() { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 5e74ce8..2d2043e 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -8,6 +8,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; @@ -62,6 +63,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactoryResolves faction catalog paths for local dev, tests, and container layouts (NEO-134). +public static class FactionCatalogPathResolution +{ + /// Walks and parents for an existing content/factions directory. + public static string? TryDiscoverFactionsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "factions"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the factions catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveFactionsDirectory(string? configuredFactionsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredFactionsDirectory)) + { + var discovered = TryDiscoverFactionsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:FactionsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/factions'). " + + "Set Content:FactionsDirectory in configuration or environment (e.g. Content__FactionsDirectory)."); + } + + var trimmed = configuredFactionsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single faction row (Draft 2020-12). + public static string ResolveFactionDefSchemaPath( + string factionsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(factionsDirectory, "..", "schemas", "faction-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..d9c5255 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Factions; + +/// DI registration for the fail-fast faction catalog (NEO-134). +public static class FactionCatalogServiceCollectionExtensions +{ + /// Binds and registers and as singletons. + public static IServiceCollection AddFactionDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Factions.FactionCatalog"); + + var factionsDir = FactionCatalogPathResolution.ResolveFactionsDirectory( + opts.FactionsDirectory, + hostEnv.ContentRootPath); + var schemaPath = FactionCatalogPathResolution.ResolveFactionDefSchemaPath( + factionsDir, + opts.FactionDefSchemaPath, + hostEnv.ContentRootPath); + + return FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, logger); + }); + + services.AddSingleton(sp => + new FactionDefinitionRegistry(sp.GetRequiredService())); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs new file mode 100644 index 0000000..4fdbb1b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Load-time faction row (NEO-134). +public sealed record FactionDefRow( + string Id, + string DisplayName, + int MinStanding, + int MaxStanding, + int NeutralStanding); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs new file mode 100644 index 0000000..5b9088a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Factions; + +/// In-memory faction catalog loaded at startup (NEO-134). Game code should prefer for lookups. +public sealed class FactionDefinitionCatalog +{ + public FactionDefinitionCatalog( + string factionsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) + { + FactionsDirectory = factionsDirectory; + ById = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory that was enumerated for *_factions.json catalogs. + public string FactionsDirectory { get; } + + public IReadOnlyDictionary ById { get; } + + public int DistinctFactionCount => ById.Count; + + /// Number of *_factions.json files under . + public int CatalogJsonFileCount { get; } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs new file mode 100644 index 0000000..68fdbc7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs @@ -0,0 +1,201 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Factions; + +/// Loads and validates content/factions/*_factions.json using the same rules as scripts/validate_content.py (NEO-134). +public static class FactionDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static FactionDefinitionCatalog Load(string factionsDirectory, string schemaPath, ILogger logger) + { + factionsDirectory = Path.GetFullPath(factionsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(factionsDirectory)) + errors.Add($"error: missing directory {factionsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(factionsDirectory, "*_factions.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_factions.json files under {factionsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var factionIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var factionsNode = rootObj["factions"]; + if (factionsNode is not JsonArray factionsArray) + { + errors.Add($"error: {path}: expected top-level 'factions' array"); + continue; + } + + for (var i = 0; i < factionsArray.Count; i++) + { + var item = factionsArray[i]; + if (item is not JsonObject rowObj) + { + errors.Add($"error: {path}: factions[{i}] must be an object"); + continue; + } + + var eval = schema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} factions[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var fid = (rowObj["id"] as JsonValue)?.GetValue(); + if (fid is not null && rowSchemaErrors == 0) + { + if (factionIdToSourceFile.TryGetValue(fid, out var prevPath)) + { + errors.Add($"error: duplicate faction id '{fid}' in {prevPath} and {path}"); + continue; + } + + factionIdToSourceFile[fid] = path; + rows[fid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var roster = PrototypeE7M3FactionCatalogRules.TryGetRosterGateError(factionIdToSourceFile); + if (roster is not null) + { + errors.Add(roster); + ThrowIfAny(errors); + } + + var standingBand = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows); + if (standingBand is not null) + { + errors.Add(standingBand); + ThrowIfAny(errors); + } + + var freeze = PrototypeE7M3FactionCatalogRules.TryGetFreezeGateError(rows); + if (freeze is not null) + { + errors.Add(freeze); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded faction catalog from {FactionsDirectory}: {FactionCount} faction(s) across {CatalogFileCount} JSON catalog file(s).", + factionsDirectory, + rows.Count, + jsonFiles.Length); + } + + return new FactionDefinitionCatalog(factionsDirectory, rows, jsonFiles.Length); + } + + private static FactionDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var minStanding = (rowObj["minStanding"] as JsonValue)!.GetValue(); + var maxStanding = (rowObj["maxStanding"] as JsonValue)!.GetValue(); + var neutralStanding = (rowObj["neutralStanding"] as JsonValue)!.GetValue(); + return new FactionDefRow(id, displayName, minStanding, maxStanding, neutralStanding); + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} factions[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Faction catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs new file mode 100644 index 0000000..277adcf --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Factions; + +/// Adapter over (NEO-134). +public sealed class FactionDefinitionRegistry(FactionDefinitionCatalog catalog) : IFactionDefinitionRegistry +{ + /// + public bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition) + { + if (factionId is null) + { + definition = null; + return false; + } + + if (catalog.ById.TryGetValue(factionId, out var row)) + { + definition = row; + return true; + } + + definition = null; + return false; + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs new file mode 100644 index 0000000..69ad348 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +public interface IFactionDefinitionRegistry +{ + /// Attempts to resolve a faction by stable id (see faction-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs b/server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs new file mode 100644 index 0000000..c579c5c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs @@ -0,0 +1,95 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Prototype E7M3 faction catalog gates (NEO-134), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M3_FACTION_* and _prototype_e7m3_faction_* gate functions. +/// +public static class PrototypeE7M3FactionCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_FACTION_IDS. + public static readonly FrozenSet ExpectedFactionIds = FrozenSet.ToFrozenSet( + [ + "prototype_faction_grid_operators", + "prototype_faction_rust_collective", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_FACTION_FREEZE. + public static readonly FrozenDictionary ExpectedFactionFreeze = + new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + ["prototype_faction_rust_collective"] = new( + "prototype_faction_rust_collective", + "Rust Collective", + -100, + 100, + 0), + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// Returns a human-readable error if the prototype faction roster fails, otherwise . + public static string? TryGetRosterGateError(IReadOnlyDictionary factionIdToSourceFile) + { + var ids = factionIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedFactionIds)) + { + return + "error: prototype E7M3 expects exactly faction ids " + + $"[{string.Join(", ", ExpectedFactionIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error if faction rows diverge from the E7M3 freeze table, otherwise . + public static string? TryGetFreezeGateError(IReadOnlyDictionary rowsById) + { + foreach (var (fid, expected) in ExpectedFactionFreeze) + { + if (!rowsById.TryGetValue(fid, out var actual)) + return $"error: missing faction '{fid}'"; + + if (!RowsEqual(expected, actual)) + { + return + $"error: faction '{fid}' must match E7M3 freeze table " + + $"(expected displayName '{expected.DisplayName}', minStanding {expected.MinStanding}, " + + $"maxStanding {expected.MaxStanding}, neutralStanding {expected.NeutralStanding}; " + + $"got displayName '{actual.DisplayName}', minStanding {actual.MinStanding}, " + + $"maxStanding {actual.MaxStanding}, neutralStanding {actual.NeutralStanding})"; + } + } + + return null; + } + + /// Returns a human-readable error if standing band ordering fails, otherwise . + public static string? TryGetStandingBandGateError(IReadOnlyDictionary rowsById) + { + foreach (var (fid, row) in rowsById) + { + if (row.MinStanding > row.NeutralStanding || row.NeutralStanding > row.MaxStanding) + { + return + $"error: faction '{fid}' requires minStanding <= neutralStanding <= maxStanding " + + $"(got minStanding {row.MinStanding}, neutralStanding {row.NeutralStanding}, maxStanding {row.MaxStanding})"; + } + } + + return null; + } + + private static bool RowsEqual(FactionDefRow left, FactionDefRow right) => + left.DisplayName == right.DisplayName && + left.MinStanding == right.MinStanding && + left.MaxStanding == right.MaxStanding && + left.NeutralStanding == right.NeutralStanding; +} diff --git a/server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs b/server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs new file mode 100644 index 0000000..f1299ef --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Minimum standing gate on (NEO-134). +public sealed record FactionGateRuleRow(string FactionId, int MinStanding); diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs new file mode 100644 index 0000000..bc925b4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs @@ -0,0 +1,235 @@ +using System.Collections.Frozen; +using NeonSprawl.Server.Game.Encounters; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Prototype E7M3 quest faction gates (NEO-134), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M3_* and _prototype_e7m3_* gate functions. +/// +public static class PrototypeE7M3QuestFactionRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID. + public const string GridContractGateFactionId = "prototype_faction_grid_operators"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING. + public const int GridContractMinStanding = 15; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID. + public const string GridContractObjectiveItemId = "survey_drone_kit"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_COMPLETION_BUNDLES. + public static readonly FrozenDictionary ExpectedCompletionBundles = + new Dictionary(StringComparer.Ordinal) + { + [PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("salvage", 25)], + []), + [PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("refine", 25)], + []), + [PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("salvage", 25)], + []), + [PrototypeE7M1QuestCatalogRules.ChainQuestId] = new( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)], + [new ReputationGrantRow("prototype_faction_grid_operators", 15)]), + [PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new( + [], + [], + [new ReputationGrantRow("prototype_faction_rust_collective", 10)]), + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// Returns a human-readable error if quest faction refs fail, otherwise . + public static string? TryGetFactionCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlySet knownFactionIds) + { + foreach (var (qid, row) in rowsById) + { + for (var gi = 0; gi < row.FactionGateRules.Count; gi++) + { + var rule = row.FactionGateRules[gi]; + if (!knownFactionIds.Contains(rule.FactionId)) + { + return + $"error: quest '{qid}' factionGateRules[{gi}]: factionId '{rule.FactionId}' " + + "is not in the frozen prototype faction catalog"; + } + } + + if (row.CompletionRewardBundle is null) + continue; + + for (var gi = 0; gi < row.CompletionRewardBundle.ReputationGrants.Count; gi++) + { + var grant = row.CompletionRewardBundle.ReputationGrants[gi]; + if (!knownFactionIds.Contains(grant.FactionId)) + { + return + $"error: quest '{qid}' completionRewardBundle.reputationGrants[{gi}]: factionId '{grant.FactionId}' " + + "is not in the frozen prototype faction catalog"; + } + } + } + + return null; + } + + /// Returns a human-readable error if bundle contents diverge from the E7M3 freeze table, otherwise . + public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary rowsById) + { + foreach (var (qid, expected) in ExpectedCompletionBundles) + { + if (!rowsById.TryGetValue(qid, out var row)) + return $"error: missing quest '{qid}'"; + + if (row.CompletionRewardBundle is null) + return $"error: quest '{qid}' must include completionRewardBundle object"; + + var actual = NormalizeBundle(row.CompletionRewardBundle); + var expectedNormalized = NormalizeBundle(expected); + if (!BundlesEqual(actual, expectedNormalized)) + { + return + $"error: quest '{qid}' completionRewardBundle must match E7M3 freeze table " + + $"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})"; + } + } + + return null; + } + + /// Returns a human-readable error if grid contract quest shape fails, otherwise . + public static string? TryGetGridContractShapeError(IReadOnlyDictionary rowsById) + { + var qid = PrototypeE7M1QuestCatalogRules.GridContractQuestId; + if (!rowsById.TryGetValue(qid, out var row)) + return $"error: missing quest '{qid}'"; + + if (!row.PrerequisiteQuestIds.SequenceEqual( + [PrototypeE7M1QuestCatalogRules.ChainQuestId], + StringComparer.Ordinal)) + { + var got = $"[{string.Join(", ", row.PrerequisiteQuestIds.Select(s => "'" + s + "'"))}]"; + return + $"error: quest '{qid}' prerequisiteQuestIds must be ['{PrototypeE7M1QuestCatalogRules.ChainQuestId}'], got {got}"; + } + + if (row.FactionGateRules.Count != 1 || + row.FactionGateRules[0].FactionId != GridContractGateFactionId || + row.FactionGateRules[0].MinStanding != GridContractMinStanding) + { + var expected = + $"[{{ factionId: '{GridContractGateFactionId}', minStanding: {GridContractMinStanding} }}]"; + var gotRules = string.Join( + ", ", + row.FactionGateRules.Select(r => $"{{ factionId: '{r.FactionId}', minStanding: {r.MinStanding} }}")); + return $"error: quest '{qid}' factionGateRules must be {expected}, got [{gotRules}]"; + } + + if (row.Steps.Count != 1) + return $"error: quest '{qid}' must have exactly one step"; + + var step = row.Steps[0]; + if (step.Objectives.Count != 1) + return $"error: quest '{qid}' must have exactly one objective"; + + var objective = step.Objectives[0]; + if (objective.Kind != "inventory_has_item") + { + return $"error: quest '{qid}' objective kind must be 'inventory_has_item'"; + } + + if (objective.ItemId != GridContractObjectiveItemId) + { + return + $"error: quest '{qid}' objective itemId must be '{GridContractObjectiveItemId}', got '{objective.ItemId}'"; + } + + if (objective.Quantity != 1) + { + return $"error: quest '{qid}' objective quantity must be 1, got {objective.Quantity}"; + } + + return null; + } + + private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) => + new( + bundle.ItemGrants + .OrderBy(g => g.ItemId, StringComparer.Ordinal) + .ThenBy(g => g.Quantity) + .ToArray(), + bundle.SkillXpGrants + .OrderBy(g => g.SkillId, StringComparer.Ordinal) + .ThenBy(g => g.Amount) + .ToArray(), + bundle.ReputationGrants + .OrderBy(g => g.FactionId, StringComparer.Ordinal) + .ThenBy(g => g.Amount) + .ToArray()); + + private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right) + { + if (left.ItemGrants.Length != right.ItemGrants.Length || + left.SkillXpGrants.Length != right.SkillXpGrants.Length || + left.ReputationGrants.Length != right.ReputationGrants.Length) + { + return false; + } + + for (var i = 0; i < left.ItemGrants.Length; i++) + { + if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId || + left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity) + { + return false; + } + } + + for (var i = 0; i < left.SkillXpGrants.Length; i++) + { + if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId || + left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount) + { + return false; + } + } + + for (var i = 0; i < left.ReputationGrants.Length; i++) + { + if (left.ReputationGrants[i].FactionId != right.ReputationGrants[i].FactionId || + left.ReputationGrants[i].Amount != right.ReputationGrants[i].Amount) + { + return false; + } + } + + return true; + } + + private static string FormatBundle(NormalizedBundle bundle) + { + var itemGrants = string.Join( + ", ", + bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}")); + var skillXpGrants = string.Join( + ", ", + bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}")); + var reputationGrants = string.Join( + ", ", + bundle.ReputationGrants.Select(g => $"{{ factionId: '{g.FactionId}', amount: {g.Amount} }}")); + return + $"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}], reputationGrants: [{reputationGrants}] }}"; + } + + private sealed record NormalizedBundle( + RewardGrantRow[] ItemGrants, + QuestSkillXpGrantRow[] SkillXpGrants, + ReputationGrantRow[] ReputationGrants); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs index 32b0430..0d9e49b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Skills; @@ -25,6 +26,7 @@ public static class QuestCatalogServiceCollectionExtensions var recipeCatalog = sp.GetRequiredService(); var encounterCatalog = sp.GetRequiredService(); var skillCatalog = sp.GetRequiredService(); + var factionCatalog = sp.GetRequiredService(); var logger = sp.GetRequiredService() .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); @@ -50,6 +52,7 @@ public static class QuestCatalogServiceCollectionExtensions var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); return QuestDefinitionCatalogLoader.Load( questsDir, @@ -62,6 +65,7 @@ public static class QuestCatalogServiceCollectionExtensions knownItemIds, knownRecipeIds, knownEncounterIds, + knownFactionIds, skillCatalog.ById, logger); }); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs index 984e314..43cc25a 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs @@ -1,12 +1,13 @@ namespace NeonSprawl.Server.Game.Quests; -/// Load-time quest row (NEO-113). +/// Load-time quest row (NEO-113, NEO-134). public sealed class QuestDefRow( string id, string displayName, IReadOnlyList prerequisiteQuestIds, IReadOnlyList steps, - QuestRewardBundleRow? completionRewardBundle = null) + QuestRewardBundleRow? completionRewardBundle = null, + IReadOnlyList? factionGateRules = null) { public string Id { get; } = id; @@ -17,4 +18,6 @@ public sealed class QuestDefRow( public IReadOnlyList Steps { get; } = steps; public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle; + + public IReadOnlyList FactionGateRules { get; } = factionGateRules ?? []; } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index 1e456d1..77e23c5 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -8,7 +8,7 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; -/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125). +/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125, NEO-134). public static class QuestDefinitionCatalogLoader { private static JsonSchema? _questObjectiveDefSchema; @@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader IReadOnlySet knownItemIds, IReadOnlySet knownRecipeIds, IReadOnlySet knownEncounterIds, + IReadOnlySet knownFactionIds, IReadOnlyDictionary skillDefsById, ILogger logger) { @@ -214,6 +215,27 @@ public static class QuestDefinitionCatalogLoader ThrowIfAny(errors); } + var factionCrossRef = PrototypeE7M3QuestFactionRules.TryGetFactionCrossRefError(rows, knownFactionIds); + if (factionCrossRef is not null) + { + errors.Add(factionCrossRef); + ThrowIfAny(errors); + } + + var e7m3BundleFreeze = PrototypeE7M3QuestFactionRules.TryGetCompletionBundleFreezeError(rows); + if (e7m3BundleFreeze is not null) + { + errors.Add(e7m3BundleFreeze); + ThrowIfAny(errors); + } + + var gridContractShape = PrototypeE7M3QuestFactionRules.TryGetGridContractShapeError(rows); + if (gridContractShape is not null) + { + errors.Add(gridContractShape); + ThrowIfAny(errors); + } + if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation( @@ -306,7 +328,27 @@ public static class QuestDefinitionCatalogLoader var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject); - return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle); + var factionGateRules = ParseFactionGateRules(rowObj["factionGateRules"] as JsonArray); + return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules); + } + + private static IReadOnlyList ParseFactionGateRules(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject ruleObj) + continue; + + var factionId = (ruleObj["factionId"] as JsonValue)!.GetValue(); + var minStanding = (ruleObj["minStanding"] as JsonValue)!.GetValue(); + rows.Add(new FactionGateRuleRow(factionId, minStanding)); + } + + return rows; } private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj) @@ -316,7 +358,27 @@ public static class QuestDefinitionCatalogLoader var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray); var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray); - return new QuestRewardBundleRow(itemGrants, skillXpGrants); + var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray); + return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants); + } + + private static IReadOnlyList ParseReputationGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var factionId = (grantObj["factionId"] as JsonValue)!.GetValue(); + var amount = (grantObj["amount"] as JsonValue)!.GetValue(); + rows.Add(new ReputationGrantRow(factionId, amount)); + } + + return rows; } private static IReadOnlyList ParseItemGrantRows(JsonArray? array) diff --git a/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs index da7bd1c..a3ba65e 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs @@ -2,7 +2,16 @@ using NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Quests; -/// Composite completion rewards on (NEO-125). +/// Composite completion rewards on (NEO-125, NEO-134). public sealed record QuestRewardBundleRow( IReadOnlyList ItemGrants, - IReadOnlyList SkillXpGrants); + IReadOnlyList SkillXpGrants, + IReadOnlyList ReputationGrants) +{ + public QuestRewardBundleRow( + IReadOnlyList itemGrants, + IReadOnlyList skillXpGrants) + : this(itemGrants, skillXpGrants, []) + { + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs b/server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs new file mode 100644 index 0000000..f6ed3a9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Reputation grant row on (NEO-134). +public sealed record ReputationGrantRow(string FactionId, int Amount); diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index e3cccbc..50f87ad 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -154,4 +154,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of quests directory}/schemas/quest-objective-def.schema.json. /// public string? QuestObjectiveDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/factions directory. + /// + public string? FactionsDirectory { get; set; } + + /// + /// Optional override for faction-def.schema.json. + /// When unset, resolved as {parent of factions directory}/schemas/faction-def.schema.json. + /// + public string? FactionDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 3aebc75..1c28fd3 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Interaction; @@ -23,6 +24,7 @@ builder.Services.AddPerkStateStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); +builder.Services.AddFactionDefinitionCatalog(builder.Configuration); builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); @@ -39,6 +41,7 @@ builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/README.md b/server/README.md index c404a0c..e25af33 100644 --- a/server/README.md +++ b/server/README.md @@ -50,6 +50,19 @@ On startup the host loads every **`*_items.json`** under the items directory, va On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52). +## Faction catalog (`content/factions`, NEO-134) + +On startup the host loads every **`*_factions.json`** under the factions directory **after** the skill catalog and **before** the quest catalog, validates each row against **`content/schemas/faction-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate faction `id`** values across files, enforces the **prototype E7M3** two-faction roster, freeze table (display names + standing band), and standing-band ordering (`minStanding <= neutralStanding <= maxStanding`), then the quest loader cross-checks **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against loaded faction ids and enforces **prototype E7M3** completion bundle freeze (item + skill + reputation grants) and **grid-contract** quest shape (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:FactionsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/factions`**. | +| **`Content:FactionDefSchemaPath`** | Optional override for **`faction-def.schema.json`**. When unset, **`{parent of factions directory}/schemas/faction-def.schema.json`**. | + +**Docker / CI:** include **`content/factions`** and **`content/schemas/faction-def.schema.json`** in the mounted **`content/`** tree; set **`Content__FactionsDirectory`** when layout differs. + +On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code. + ## Resource-node catalog (`content/resource-nodes`, NEO-58) On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. @@ -135,7 +148,7 @@ On success, **Information** logs include the resolved encounters directory path, ## Quest catalog (`content/quests`, NEO-113) -On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, and **skill** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, and **`reward-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, and enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents, item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. +On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, **skill**, and **faction** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, enforces the **prototype E7M3** five-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents (item + skill grants), item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets, and enforces **prototype E7M3** faction cross-refs on gates and reputation grants, E7M3 completion bundle freeze (including **`reputationGrants`**), and **grid-contract** quest shape (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| @@ -146,7 +159,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory * Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**) resolve as siblings under **`{parent of quests directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those three files next to the quest schemas (NEO-125). -**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**), and quest/bundle schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. +**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**, **`content/factions`**), and quest/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.