Compare commits
29 Commits
33cfc62c66
...
0af21e002c
| Author | SHA1 | Date |
|---|---|---|
|
|
0af21e002c | |
|
|
a36e7b56b5 | |
|
|
4ffc60f226 | |
|
|
0241cc31e3 | |
|
|
1c3d075d6b | |
|
|
25aba88e78 | |
|
|
ddaf33ce87 | |
|
|
2117b2f8d3 | |
|
|
536f635a11 | |
|
|
8aae9625e7 | |
|
|
e9abc8bf38 | |
|
|
786a5b6d68 | |
|
|
94acf57e5b | |
|
|
2ac804ed6c | |
|
|
d5f16f4cf4 | |
|
|
c066121c9d | |
|
|
4fca45bf7c | |
|
|
08ebfcebcf | |
|
|
d90d325a3d | |
|
|
aa3eb3597c | |
|
|
058799a595 | |
|
|
48bca98467 | |
|
|
edb33514c5 | |
|
|
f248022c2d | |
|
|
635f3cccaf | |
|
|
6cb7dfbb46 | |
|
|
92c078b0e3 | |
|
|
0948d9d4a8 | |
|
|
718346163f |
|
|
@ -93,6 +93,7 @@ jobs:
|
|||
ConnectionStrings__NeonSprawl: >-
|
||||
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
||||
Game__EnableCombatTargetFixtureApi: "true"
|
||||
Game__EnableQuestFixtureApi: "true"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: faction-catalog
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (faction standing store NEO-135)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-135 registers IFactionStandingStore and IReputationDeltaStore (in-memory or Postgres when configured). No faction standing HTTP API in this story — use this request to confirm the host started after store DI wiring.
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: faction-standing
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ tests {
|
|||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.quests).to.be.an("array");
|
||||
expect(body.quests.length).to.equal(4);
|
||||
expect(body.quests.length).to.equal(5);
|
||||
});
|
||||
|
||||
test("quests are ascending by id (ordinal)", function () {
|
||||
|
|
@ -54,4 +54,19 @@ tests {
|
|||
quantity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("prototype_quest_grid_contract has operator-chain prereq and kit hand-in objective", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.quests.find((x) => x.id === "prototype_quest_grid_contract");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Grid Contract");
|
||||
expect(row.prerequisiteQuestIds).to.eql(["prototype_quest_operator_chain"]);
|
||||
expect(row.steps).to.have.length(1);
|
||||
expect(row.steps[0].objectives[0]).to.eql({
|
||||
id: "grid_contract_obj_kit",
|
||||
kind: "inventory_has_item",
|
||||
itemId: "survey_drone_kit",
|
||||
quantity: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
meta {
|
||||
name: Accept grid contract faction gate deny
|
||||
type: http
|
||||
seq: 10
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15.
|
||||
Pre-request marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing stays 0).
|
||||
Success Bruno deferred to NEO-138 when operator-chain rep grants land.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||
const chainQuestId = "prototype_quest_operator_chain";
|
||||
|
||||
const fixture = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
completedQuestIds: [chainQuestId],
|
||||
},
|
||||
{ ...jsonHeaders, validateStatus: () => true },
|
||||
);
|
||||
if (fixture.status !== 200 || fixture.data?.applied !== true) {
|
||||
throw new Error(
|
||||
`quest-fixture failed: HTTP ${fixture.status} ${JSON.stringify(fixture.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_grid_contract/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies accept with faction_gate_blocked when standing below minStanding", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("faction_gate_blocked");
|
||||
expect(body.quest).to.equal(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started.
|
||||
NEO-119: fresh dev-local-1 row before any quest accept — all five catalog quests not_started.
|
||||
}
|
||||
|
||||
get {
|
||||
|
|
@ -21,7 +21,7 @@ tests {
|
|||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
|
||||
expect(body.quests).to.be.an("array");
|
||||
expect(body.quests.length).to.equal(4);
|
||||
expect(body.quests.length).to.equal(5);
|
||||
});
|
||||
|
||||
test("all prototype quests are not_started with empty counters", function () {
|
||||
|
|
@ -29,6 +29,7 @@ tests {
|
|||
const expectedIds = [
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_grid_contract",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_refine_intro",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
| [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) |
|
||||
| [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
|
||||
| [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
|
||||
| [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) |
|
||||
| [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) / [E7.M3](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md) |
|
||||
| [`factions/`](factions/) | Faction catalogs; each row matches [`schemas/faction-def.schema.json`](schemas/faction-def.schema.json) — **stable `id`**, standing band for [E7.M3](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md) |
|
||||
| [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) |
|
||||
|
||||
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
|
||||
|
|
@ -32,7 +33,9 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
|
||||
**Prototype E7 Slice 1 — quests (NEO-112):** CI expects **exactly four** quest ids aligned to [E7.M1 quest freeze](../docs/decomposition/modules/E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01) — **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`**, **`prototype_quest_operator_chain`**. Objective **`itemId`** / **`recipeId`** / **`encounterId`** cross-refs must resolve to frozen item, recipe, and encounter catalogs; **`prerequisiteQuestIds`** must be acyclic and reference known quest ids; operator chain terminal step is **`inventory_has_item`** **`contract_handoff_token`** ×1. **Do not rename** quest `id` after ship—change **`displayName`** only. See [E7M1-prototype-backlog.md](../docs/plans/E7M1-prototype-backlog.md) and [NEO-112 plan](../docs/plans/NEO-112-implementation-plan.md).
|
||||
|
||||
**Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each of the four frozen quest ids must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md).
|
||||
**Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each frozen quest id must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md).
|
||||
|
||||
**Prototype E7 Slice 3 — factions + gated quest line (NEO-133):** CI expects **exactly two** faction ids aligned to [E7.M3 faction freeze](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01) — **`prototype_faction_grid_operators`**, **`prototype_faction_rust_collective`**. Quest roster expands to **five** ids (four E7.M1 onboarding + **`prototype_quest_grid_contract`**). **`factionGateRules[].factionId`** and **`completionRewardBundle.reputationGrants[].factionId`** must resolve to the frozen faction catalog; grid contract shape is frozen in CI. See [E7M3-pre-production-backlog.md](../docs/plans/E7M3-pre-production-backlog.md) and [NEO-133 plan](../docs/plans/NEO-133-implementation-plan.md).
|
||||
|
||||
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"factions": [
|
||||
{
|
||||
"id": "prototype_faction_grid_operators",
|
||||
"displayName": "Grid Operators",
|
||||
"minStanding": -100,
|
||||
"maxStanding": 100,
|
||||
"neutralStanding": 0
|
||||
},
|
||||
{
|
||||
"id": "prototype_faction_rust_collective",
|
||||
"displayName": "Rust Collective",
|
||||
"minStanding": -100,
|
||||
"maxStanding": 100,
|
||||
"neutralStanding": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -76,7 +76,10 @@
|
|||
],
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
|
||||
"reputationGrants": [
|
||||
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
|
|
@ -128,6 +131,36 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prototype_quest_grid_contract",
|
||||
"displayName": "Grid Contract",
|
||||
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
|
||||
"factionGateRules": [
|
||||
{
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 15
|
||||
}
|
||||
],
|
||||
"completionRewardBundle": {
|
||||
"reputationGrants": [
|
||||
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "grid_contract_step_kit",
|
||||
"displayName": "Deliver survey drone kit",
|
||||
"objectives": [
|
||||
{
|
||||
"id": "grid_contract_obj_kit",
|
||||
"kind": "inventory_has_item",
|
||||
"itemId": "survey_drone_kit",
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/faction-def.json",
|
||||
"title": "FactionDef",
|
||||
"description": "Single faction row for catalogs (e.g. content/factions/*_factions.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "displayName", "minStanding", "maxStanding", "neutralStanding"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable faction key for standing, gates, telemetry, and reputation grants."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating character data."
|
||||
},
|
||||
"minStanding": {
|
||||
"type": "integer",
|
||||
"description": "Lowest standing value after clamp on apply."
|
||||
},
|
||||
"maxStanding": {
|
||||
"type": "integer",
|
||||
"description": "Highest standing value after clamp on apply."
|
||||
},
|
||||
"neutralStanding": {
|
||||
"type": "integer",
|
||||
"description": "Default standing for new players when no snapshot exists."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/faction-gate-rule.json",
|
||||
"title": "FactionGateRule",
|
||||
"description": "Minimum faction standing required to accept gated content (e.g. on QuestDef.factionGateRules). See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["factionId", "minStanding"],
|
||||
"properties": {
|
||||
"factionId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Faction catalog id that must meet minStanding."
|
||||
},
|
||||
"minStanding": {
|
||||
"type": "integer",
|
||||
"description": "Inclusive minimum standing required to pass the gate."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,9 +33,16 @@
|
|||
},
|
||||
"description": "Ordered steps; prototype Slice 1 uses sequential advance only."
|
||||
},
|
||||
"factionGateRules": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/faction-gate-rule.json"
|
||||
},
|
||||
"description": "Optional accept gates evaluated against faction standing (E7.M3)."
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
|
||||
"description": "Optional completion payout (E7.M2); required on all four prototype quest ids in CI."
|
||||
"description": "Optional completion payout (E7.M2/E7.M3); required on all prototype quest ids in CI."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
|
||||
"title": "QuestRewardBundle",
|
||||
"description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds v1: itemGrants + skillXpGrants only. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md.",
|
||||
"description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds: itemGrants, skillXpGrants, reputationGrants. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md and E7_M3_FactionReputationLedger.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
|
@ -19,6 +19,13 @@
|
|||
"$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json"
|
||||
},
|
||||
"description": "Skill XP rows applied via mission_reward on first-time completion."
|
||||
},
|
||||
"reputationGrants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/reputation-grant-row.json"
|
||||
},
|
||||
"description": "Faction reputation rows applied on first-time completion (E7.M3)."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
|
|
@ -37,6 +44,14 @@
|
|||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": ["reputationGrants"],
|
||||
"properties": {
|
||||
"reputationGrants": {
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/reputation-grant-row.json",
|
||||
"title": "ReputationGrantRow",
|
||||
"description": "Faction reputation payout row on QuestRewardBundle.reputationGrants. See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["factionId", "amount"],
|
||||
"properties": {
|
||||
"factionId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Faction catalog id receiving the standing change."
|
||||
},
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"description": "Signed reputation delta applied on first-time completion delivery."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
- Responsibility: Track faction standing and gate faction-specific contracts/content.
|
||||
- Key contracts: `FactionStanding`, `ReputationDelta`, `FactionGateRule`
|
||||
- Dependencies: E7.M1
|
||||
- Dependencies: E7.M1, E7.M2
|
||||
- Stage target: Pre-production
|
||||
|
||||
### E7.M4 - ContractMissionGenerator
|
||||
|
|
@ -68,14 +68,19 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
**Linear backlog:** [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) — [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Verify quest completion rewards in **Godot**, not Bruno-only. Encounter loot ([E5.M3](../modules/E5_M3_EncounterAndRewardTables.md)) unchanged in Slice 2.
|
||||
|
||||
<a id="slice-3---faction-ledger-pre-production"></a>
|
||||
|
||||
### Slice 3 - Faction ledger (pre-production)
|
||||
|
||||
- Scope: E7.M3 gating at least one faction line and mission set.
|
||||
- Dependencies: E7.M1
|
||||
- Dependencies: E7.M1, E7.M2
|
||||
- Acceptance criteria:
|
||||
- Reputation deltas auditable; gates fail closed on tamper.
|
||||
- Player earns Grid Operators rep from operator chain; faction-gated quest accept denied below threshold.
|
||||
- Telemetry hooks: `reputation_delta`, `faction_gate_blocked`.
|
||||
|
||||
**Linear backlog:** [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) — [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Verify faction standing + gate deny in **Godot**, not Bruno-only.
|
||||
|
||||
### Slice 4 - Contract generator (pre-production)
|
||||
|
||||
- Scope: E7.M4 emitting repeatable contracts from templates by zone difficulty.
|
||||
|
|
@ -97,4 +102,5 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
- Prototype quest counts and integrated chain meet master plan minimums **in the Godot client** (Slice 1 capstone [NEO-123](../../manual-qa/NEO-123.md)), not Bruno-only.
|
||||
- Slice 2 quest **completion** rewards (items + skill XP) visible in Godot with idempotent delivery (capstone [NEO-132](../../manual-qa/NEO-132.md)), not Bruno-only.
|
||||
- Pre-production adds faction + contracts with validation.
|
||||
- Slice 3 faction rep + gated quest line visible in Godot (capstone [NEO-143](../../manual-qa/NEO-143.md)), not Bruno-only.
|
||||
- Pre-production adds contracts (E7.M4) with validation beyond Slice 3.
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
| **`prototype_quest_combat_intro`** | Intro: Clear the Pocket | *(none)* | 1× **`encounter_complete`**: **`prototype_combat_pocket`** |
|
||||
| **`prototype_quest_operator_chain`** | Operator Chain | **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`** | 4 steps: gather **`scrap_metal_bulk` ×5** → craft **`refine_scrap_standard`** → craft **`make_field_stim_mk0`** → **`inventory_has_item`** **`contract_handoff_token` ×1** |
|
||||
|
||||
**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
|
||||
**CI enforcement (NEO-112):** `scripts/validate_content.py` still enforces the **four Slice 1 quest ids** in the freeze table above (prerequisite graph, objective cross-refs, chain terminal step). **Roster allowlist** expanded to **five** quests under [E7.M3](E7_M3_FactionReputationLedger.md) (`PROTOTYPE_E7M3_QUEST_IDS`, NEO-133) — adds **`prototype_quest_grid_contract`**. Shared gates: objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; operator chain terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
|
||||
|
||||
**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,38 +7,82 @@
|
|||
| **Module ID** | E7.M3 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Pre-production |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-06** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **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
|
||||
|
||||
Faction standing, reputation deltas, and `FactionGateRule` evaluation for content and contracts.
|
||||
Character-wide **faction standing**, auditable **reputation deltas**, and **`FactionGateRule`** evaluation for quest accept (and future zone/travel gates via [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)). Rep grants flow through [E7.M2](E7_M2_RewardAndUnlockRouter.md) **`completionRewardBundle`** idempotency.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Persist auditable `FactionStanding`; apply `ReputationDelta`; fail closed on tamper for gates.
|
||||
- Persist **`FactionStanding`** per player per faction; apply **`ReputationDelta`** with clamp and audit log.
|
||||
- Evaluate **`FactionGateRule`** on quest accept — fail closed on unknown faction or tampered rules (startup validation).
|
||||
- Emit telemetry hook sites: **`reputation_delta`**, **`faction_gate_blocked`**.
|
||||
|
||||
## Key contracts
|
||||
|
||||
| Contract | Role |
|
||||
|----------|------|
|
||||
| `FactionStanding` | Current rep per faction. |
|
||||
| `ReputationDelta` | Change events with source attribution. |
|
||||
| `FactionGateRule` | Unlock conditions for content. |
|
||||
| `FactionDef` | Faction id, display name, min/max/neutral standing. |
|
||||
| `FactionStanding` | Current rep per faction for a player. |
|
||||
| `ReputationDelta` | Signed change with source attribution (quest id, etc.). |
|
||||
| `FactionGateRule` | Minimum standing required (e.g. on `QuestDef`). |
|
||||
| `reputationGrants` | Rows on `QuestRewardBundle` — `factionId` + `amount`. |
|
||||
|
||||
## Module dependencies
|
||||
|
||||
- **E7.M1** — QuestStateMachine.
|
||||
- **E7.M1** — QuestStateMachine (`TryAccept` gate hook site).
|
||||
- **E7.M2** — RewardAndUnlockRouter (bundle apply + idempotent delivery store).
|
||||
|
||||
## Dependents (by design)
|
||||
|
||||
- **E7.M4** — ContractMissionGenerator.
|
||||
- **E8.M2** — GuildCorpProgressionState.
|
||||
- **E4.M1** — ZoneGraphAndTravelRules (`TravelRule` faction gates).
|
||||
|
||||
## Related implementation slices
|
||||
|
||||
Epic 7 **Slice 3** — `reputation_delta`, `faction_gate_blocked`.
|
||||
Epic 7 **Slice 3** — at least one faction quest line with rep grant + gated accept; auditable deltas; telemetry **`reputation_delta`**, **`faction_gate_blocked`**. Backlog: [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md).
|
||||
|
||||
## Linear backlog (decomposed)
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E7M3-01 | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) |
|
||||
| E7M3-02 | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) |
|
||||
| E7M3-03 | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) |
|
||||
| E7M3-04 | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) |
|
||||
| E7M3-05 | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) |
|
||||
| E7M3-06 | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) |
|
||||
| E7M3-07 | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) |
|
||||
| E7M3-08 | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) |
|
||||
| E7M3-09 | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) |
|
||||
| E7M3-10 | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) |
|
||||
| E7M3-11 | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) |
|
||||
|
||||
## Prototype Slice 3 freeze (E7M3-01)
|
||||
|
||||
### Faction catalog
|
||||
|
||||
| `FactionDef.id` | `displayName` | Standing band |
|
||||
|-----------------|---------------|---------------|
|
||||
| **`prototype_faction_grid_operators`** | Grid Operators | -100…100 (neutral 0) |
|
||||
| **`prototype_faction_rust_collective`** | Rust Collective | -100…100 (neutral 0) |
|
||||
|
||||
### Quest content changes
|
||||
|
||||
| `QuestDef.id` | Rep / gate |
|
||||
|---------------|------------|
|
||||
| **`prototype_quest_operator_chain`** | **`completionRewardBundle.reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (existing item + skill XP unchanged) |
|
||||
| **`prototype_quest_grid_contract`** *(new)* | **`prerequisiteQuestIds`**: operator chain; **`factionGateRules`**: Grid Operators **`minStanding` 15**; objective **`inventory_has_item`** **`survey_drone_kit` ×1**; completion rep **Rust Collective +10** |
|
||||
|
||||
**Roster:** five quests total (four E7.M1 onboarding + one faction line). E7.M1 four-quest freeze expands under E7M3 CI gates (`PROTOTYPE_E7M3_QUEST_IDS` in `scripts/validate_content.py`).
|
||||
|
||||
**CI enforcement (NEO-133):** `scripts/validate_content.py` validates `content/factions/*_factions.json`, five-quest roster, faction cross-refs on gates and `reputationGrants`, E7M3 completion bundle freeze, and grid-contract shape.
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7, faction reputation.
|
||||
- Game design: [skills.md — Rep / story](../../../docs/game-design/skills.md) (character-wide faction rep).
|
||||
- [Module dependency register](module_dependency_register.md)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -113,7 +113,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready |
|
||||
|
||||
**E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`NEO-132` manual QA](../../manual-qa/NEO-132.md), [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132), plan [NEO-132](../../plans/NEO-132-implementation-plan.md). Epic 7 Slice 2 client capstone complete.
|
||||
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
|
||||
| E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | In Progress |
|
||||
|
||||
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. Client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143).
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
||||
|
||||
### Epic 8 — Social / Guild
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
| 11 | **E7M1-11** | client | E7M1-08, E7M1-09 |
|
||||
| 12 | **E7M1-12** | client | E7M1-11 |
|
||||
|
||||
**Downstream (separate modules):** [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) Slice 2 backlog [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)); [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) faction ledger (pre-production); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
**Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Working backlog for **Epic 7 — Slice 2** ([reward and unlock routing](../decom
|
|||
| 8 | **E7M2-08** | client | E7M2-06 |
|
||||
| 9 | **E7M2-09** | client | E7M2-08 |
|
||||
|
||||
**Downstream (separate modules):** [E6.M4](../decomposition/modules/E6_M4_RewardParityEnforcer.md) lint against bundles; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) reputation grants (pre-production); E5.M3 **`EncounterCompleteEvent`** full router consumer (optional follow-on).
|
||||
**Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
# E7.M3 — Pre-production story backlog (FactionReputationLedger)
|
||||
|
||||
Working backlog for **Epic 7 — Slice 3** ([faction ledger](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production)). Decomposition and contracts: [E7.M3 — FactionReputationLedger](../decomposition/modules/E7_M3_FactionReputationLedger.md).
|
||||
|
||||
**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E7M2 (paired server+client)](../plans/E7M2-prototype-backlog.md), [E7M1 (quest HUD + capstone)](../plans/E7M1-prototype-backlog.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E7.M3`**; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Precursor (do not re-scope):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) **Ready** — quest accept/advance/complete ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)–[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)). [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **Ready** — idempotent **`completionRewardBundle`** delivery ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)). Slice 3 **extends** E7.M2 bundles with **`reputationGrants`**; does not migrate encounter loot or duplicate quest completion grants.
|
||||
|
||||
**Upstream (must be landed):** E7.M1 quest state + accept gates; E7.M2 **`RewardRouterOperations`** + **`IRewardDeliveryStore`**; [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md) solo idempotency.
|
||||
|
||||
**Prototype faction spine (frozen in E7M3-01):** **two** frozen `FactionDef` ids; **one** reputation grant on **`prototype_quest_operator_chain`**; **one** faction-gated follow-on quest **`prototype_quest_grid_contract`**.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E7M3-01 | server | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) |
|
||||
| E7M3-02 | server | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) |
|
||||
| E7M3-03 | server | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) |
|
||||
| E7M3-04 | server | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) |
|
||||
| E7M3-05 | server | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) |
|
||||
| E7M3-06 | server | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) |
|
||||
| E7M3-07 | server | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) |
|
||||
| E7M3-08 | server | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) |
|
||||
| E7M3-09 | server | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) |
|
||||
| E7M3-10 | client | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) |
|
||||
| E7M3-11 | client | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) |
|
||||
|
||||
**Dependency graph in Linear:** E7M3-02 blocked by E7M3-01. E7M3-03 blocked by E7M3-02. E7M3-04 blocked by E7M3-03. E7M3-05 blocked by E7M3-02 and E7M3-03. E7M3-06 blocked by E7M3-04. E7M3-07 blocked by E7M3-03. E7M3-08 blocked by E7M3-06 and E7M3-07. E7M3-09 blocked by E7M3-04 and E7M3-05. E7M3-10 blocked by E7M3-07 and E7M3-08. E7M3-11 blocked by E7M3-10.
|
||||
|
||||
**Board order:** estimates **1–11** matching slug order (E7M3-01 = 1 … E7M3-11 = 11). On the Epic 7 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E7M3-01** | server | E7.M1/E7.M2 content frozen |
|
||||
| 2 | **E7M3-02** | server | E7M3-01 |
|
||||
| 3 | **E7M3-03** | server | E7M3-02 |
|
||||
| 4 | **E7M3-04** | server | E7M3-03 |
|
||||
| 5 | **E7M3-05** | server | E7M3-02, E7M3-03 |
|
||||
| 6 | **E7M3-06** | server | E7M3-04 |
|
||||
| 7 | **E7M3-07** | server | E7M3-03 |
|
||||
| 8 | **E7M3-08** | server | E7M3-06, E7M3-07 |
|
||||
| 9 | **E7M3-09** | server | E7M3-04, E7M3-05 |
|
||||
| 10 | **E7M3-10** | client | E7M3-07, E7M3-08 |
|
||||
| 11 | **E7M3-11** | client | E7M3-10 |
|
||||
|
||||
**Downstream (separate modules):** [E7.M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md) contract generator; [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) **`TravelRule`** faction gates; [E8.M2](../decomposition/modules/E8_M2_GuildCorpProgressionState.md) guild/corp rep coupling.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Faction count | **Two** frozen ids (corp + street) | Readable contrast; Slice 3 AC needs **one** gated line — corp line is flagship |
|
||||
| Standing range | **-100…100**, neutral **0** | Standard RPG band; clamp on apply |
|
||||
| Rep grants | **`reputationGrants`** rows on **`completionRewardBundle`** | Reuse E7.M2 idempotent delivery path; audit via delivery store |
|
||||
| Gate evaluation | **`TryAccept`** only (quest accept) | E4.M1 travel gates deferred; epic Slice 3 is quest/mission gating |
|
||||
| Gate deny | **`faction_gate_blocked`** reason code | Epic telemetry vocabulary |
|
||||
| Fail-closed | Unknown **`factionId`** in grant or gate → deny | Tamper / bad content cannot silently pass |
|
||||
| Delta audit | Append-only **`ReputationDelta`** log per apply | Slice 3 AC: auditable deltas |
|
||||
| Idempotency | Rep grants ride **`{playerId}:quest_complete:{questId}`** delivery key | Same as E7.M2; replay does not double-apply standing |
|
||||
| Party mode | **Solo only** | E8.M1 not landed |
|
||||
| Quest roster | **Five** quests (four E7.M1 + one faction line) | Operator chain grants rep; gated quest proves accept gate |
|
||||
| HUD fidelity | Prototype labels / debug panels | NEO-122 / NEO-131 precedent |
|
||||
|
||||
### Prototype faction freeze (E7M3-01)
|
||||
|
||||
| `FactionDef.id` | `displayName` | `minStanding` | `maxStanding` | `neutralStanding` |
|
||||
|-----------------|---------------|---------------|---------------|---------------------|
|
||||
| **`prototype_faction_grid_operators`** | Grid Operators | -100 | 100 | 0 |
|
||||
| **`prototype_faction_rust_collective`** | Rust Collective | -100 | 100 | 0 |
|
||||
|
||||
### Prototype reputation + gate freeze (E7M3-01)
|
||||
|
||||
| Quest id | Change | Detail |
|
||||
|----------|--------|--------|
|
||||
| **`prototype_quest_operator_chain`** | Extend **`completionRewardBundle`** | Add **`reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (item + skill XP rows unchanged) |
|
||||
| **`prototype_quest_grid_contract`** | **New** quest | **`prerequisiteQuestIds`**: **`prototype_quest_operator_chain`**; **`factionGateRules`**: **`prototype_faction_grid_operators`** **`minStanding` 15**; 1 step **`inventory_has_item`** **`survey_drone_kit` ×1**; completion bundle: **`prototype_faction_rust_collective`** **+10** rep only |
|
||||
|
||||
Accepting **`prototype_quest_grid_contract`** before operator-chain completion or before **15** Grid Operators standing returns **`faction_gate_blocked`** or **`prerequisite_incomplete`** respectively.
|
||||
|
||||
---
|
||||
|
||||
### E7M3-01 — FactionDef catalog + quest gate/rep schemas + CI
|
||||
|
||||
**Goal:** Lock faction content shape, extend quest schemas for gates + rep grants, and CI validation before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/faction-def.schema.json`, `content/schemas/faction-gate-rule.schema.json`, `content/schemas/reputation-grant-row.schema.json`.
|
||||
- Extend `content/schemas/quest-def.schema.json` with optional **`factionGateRules`** array.
|
||||
- Extend `content/schemas/quest-reward-bundle.schema.json` with optional **`reputationGrants`** array.
|
||||
- `content/factions/prototype_factions.json` — two frozen faction rows.
|
||||
- Update `content/quests/prototype_quests.json` — operator-chain rep grant + fifth quest per freeze table.
|
||||
- `scripts/validate_content.py`: faction schema validation, quest gate cross-refs, rep grant cross-refs, five-quest allowlist (four E7.M1 + **`prototype_quest_grid_contract`**), E7M2 bundle freeze updated for operator-chain rep row.
|
||||
- Designer note in [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, stores, operations, HTTP, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates faction JSON and extended quest JSON.
|
||||
- [x] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs.
|
||||
- [x] Invalid faction refs in gates or grants fail CI.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-02 — Server faction catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/factions/*_factions.json` with CI-parity validation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + registry types under `server/NeonSprawl.Server/Game/Factions/` (or `Game/Reputation/`).
|
||||
- **`IFactionDefinitionRegistry`** + DI registration.
|
||||
- Cross-check quest **`factionGateRules`** / bundle **`reputationGrants`** faction ids at startup (after quest catalog load).
|
||||
- Unit tests (AAA) for happy path and broken faction refs.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-player standing store, apply logic, HTTP.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup.
|
||||
- [ ] Registry resolves faction metadata by id.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-03 — Faction standing store + reputation delta audit store
|
||||
|
||||
**Goal:** Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`IFactionStandingStore`**, **`IReputationDeltaStore`** — in-memory implementations; optional Postgres migrations (NEO-116-style split).
|
||||
- Default standing **0** per known faction when missing; clamp reads to faction min/max.
|
||||
- Audit row: player, faction, delta amount, resulting standing, source kind (`quest_completion`), source id (quest id), timestamp/id.
|
||||
- Unit tests: first apply, clamp at bounds, audit append.
|
||||
- `server/README.md` store sections.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Public apply API surface (E7M3-04), quest wiring (E7M3-06).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests.
|
||||
- [ ] Unknown faction id returns structured deny at store boundary.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-04 — `ReputationOperations` (apply `ReputationDelta`)
|
||||
|
||||
**Goal:** Server-internal apply for standing changes with clamp, audit, and structured outcomes.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`.
|
||||
- Validates faction id via **`IFactionDefinitionRegistry`**; fail-closed on unknown faction.
|
||||
- Emits audit **`ReputationDelta`** row on success.
|
||||
- Unit + integration tests (AAA): happy path, clamp min/max, unknown faction deny.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Quest bundle wiring (E7M3-06), accept gates (E7M3-05).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Delta apply updates standing once and records audit row.
|
||||
- [ ] Standing never exceeds faction min/max after apply.
|
||||
|
||||
**Client counterpart:** none (server engine).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-05 — `FactionGateOperations` + wire into `QuestStateOperations.TryAccept`
|
||||
|
||||
**Goal:** Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`FactionGateOperations.TryEvaluate`** — all rules must pass (AND); compare player standing vs **`minStanding`** per rule.
|
||||
- Call from **`TryAccept`** after prerequisite check, before **`TryActivate`**.
|
||||
- New reason code **`faction_gate_blocked`** in **`QuestStateReasonCodes`** + accept deny telemetry hook site.
|
||||
- Integration tests: accept gated quest denied below threshold; succeeds at threshold; unknown faction in rule fails closed at startup (E7M3-02) not runtime.
|
||||
- Bruno smoke for accept deny/success on **`prototype_quest_grid_contract`**.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Zone/travel gates (E4.M1); Godot HUD (E7M3-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
|
||||
- [ ] Accept succeeds when all rules satisfied.
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD surfacing (blocked by E7M3-07/E7M3-08).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-06 — Extend `RewardRouterOperations` for `reputationGrants`
|
||||
|
||||
**Goal:** Quest completion bundles apply faction rep grants idempotently with item/skill XP rows.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend **`RewardRouterOperations.TryDeliverQuestCompletion`** to apply **`reputationGrants`** via **`ReputationOperations`** after validation.
|
||||
- Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** snapshot with rep grant lines.
|
||||
- Compensating rollback policy: if rep apply fails after items/xp committed, document rollback behavior (mirror E7.M2 partial-failure policy).
|
||||
- Integration tests: operator-chain completion → +15 Grid Operators once; replay idempotent.
|
||||
- Extend Bruno quest-progress smoke for standing side effect (optional subfolder).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP read projection (E7M3-08), Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] First-time quest completion applies rep grants exactly once per delivery key.
|
||||
- [ ] Unknown faction id in bundle denies delivery before store record.
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-07 — `GET /game/players/{id}/faction-standing` HTTP read
|
||||
|
||||
**Goal:** Client-readable standing snapshot for all frozen factions.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`GET /game/players/{id}/faction-standing`** — `FactionStandingApi` + DTOs in `Game/Factions/`.
|
||||
- Response lists each catalog faction with current standing (default 0).
|
||||
- Integration tests + Bruno `bruno/neon-sprawl-server/faction-standing/`.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (E7M3-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET returns stable JSON for prototype player after quest rep grants.
|
||||
- [ ] Unknown player returns **404** (position gate precedent).
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-08 — Extend quest HTTP projections for rep grants + gate rules
|
||||
|
||||
**Goal:** Client can see gate requirements and completion rep summary without local math.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend **`GET /game/world/quest-definitions`** with optional **`factionGateRules`** projection per quest.
|
||||
- Extend **`GET …/quest-progress`** **`completionRewardSummary`** with **`reputationGrants`** lines (mirror items/skill XP).
|
||||
- Update **`QuestAcceptApi`** embedded quest rows if applicable.
|
||||
- Integration tests + Bruno assertions on grid-contract quest row.
|
||||
- `server/README.md` quest + faction sections.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot parse/HUD (E7M3-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Gated quest definition includes **`factionGateRules`** in world GET.
|
||||
- [ ] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered.
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-09 — Faction telemetry hook sites (comment-only)
|
||||
|
||||
**Goal:** Anchor future E9.M1 catalog events without ingest.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Comment-only **`reputation_delta`** in **`ReputationOperations`** (successful apply).
|
||||
- Comment-only **`faction_gate_blocked`** in **`FactionGateOperations`** / **`TryAccept`** deny path.
|
||||
- `server/README.md` hook list.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- E9.M1 ingest, dashboards.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook sites documented in README; no behavior change beyond comments.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-10 — Client faction standing + gate feedback HUD (Godot)
|
||||
|
||||
**Goal:** Player sees faction standing and accept gate denials in Godot without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`faction_standing_client.gd`**, **`FactionStandingLabel`** (or extend economy HUD section).
|
||||
- Parse **`GET …/faction-standing`**; refresh after quest completion transitions (reuse **`quest_hud_controller.gd`** signals).
|
||||
- Surface **`faction_gate_blocked`** on accept feedback label (NEO-122 pattern).
|
||||
- Extend **`quest_progress_client.gd`** / reward label for **`reputationGrants`** lines in **`completionRewardSummary`**.
|
||||
- GdUnit parse tests; `client/README.md` section.
|
||||
- `docs/manual-qa/NEO-142.md` component checklist.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final journal UI; faction art pass.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Standing visible after operator-chain completion (+15 Grid Operators).
|
||||
- [ ] Accepting grid contract below rep shows readable deny copy on HUD.
|
||||
|
||||
**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-11 — Playable faction reputation + gate capstone (Godot)
|
||||
|
||||
**Goal:** Prove Epic 7 Slice 3 acceptance **in Godot**: earn rep from operator chain, accept gated faction quest once; gates fail closed.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`docs/manual-qa/NEO-143.md`**: capstone session — complete operator chain → verify standing **15** → accept **`prototype_quest_grid_contract`** → complete hand-in → Rust Collective **+10**; retry accept gate before rep (denied).
|
||||
- **`client/README.md`**: **End-to-end faction reputation loop** section; cross-links NEO-133–NEO-142.
|
||||
- Module alignment on completion: [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [module_dependency_register.md](../decomposition/modules/module_dependency_register.md).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Contract generator (E7.M4); zone travel gates (E4.M1); Bruno-only proof.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes **`docs/manual-qa/NEO-143.md`** with server + client.
|
||||
- [ ] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (unknown faction denied at startup).
|
||||
- [ ] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production).
|
||||
|
||||
**Client counterpart:** this story **is** the Slice 3 client capstone.
|
||||
|
||||
---
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [x] Every player-visible AC has a **`client`** Linear issue (NEO-142, NEO-143)
|
||||
- [x] No forbidden deferral language in backlog
|
||||
- [x] Capstone client story exists (E7M3-11 / NEO-143)
|
||||
- [x] Epic DoD updated for Godot verification (see epic_07 update)
|
||||
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row updated (decomposition pass)
|
||||
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M3 note updated
|
||||
|
||||
## Related docs
|
||||
|
||||
- [epic_07_quest_faction.md](../decomposition/epics/epic_07_quest_faction.md)
|
||||
- [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md)
|
||||
- [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md)
|
||||
- [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md)
|
||||
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# NEO-133 — E7M3-01: FactionDef catalog + quest gate/rep schemas + CI
|
||||
|
||||
**Linear:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)
|
||||
**Branch:** `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci`
|
||||
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-01**
|
||||
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
|
||||
**Pattern:** [NEO-124-implementation-plan.md](NEO-124-implementation-plan.md) (schemas + catalog + `validate_content.py` gates)
|
||||
|
||||
## Goal
|
||||
|
||||
Land faction and quest content contracts for Epic 7 Slice 3 so **CI** validates faction JSON, extended quest fields (`factionGateRules`, `completionRewardBundle.reputationGrants`), and the frozen five-quest prototype roster **before** NEO-134 server faction catalog load.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
Decisions are already frozen in [E7M3-pre-production-backlog.md — Kickoff decisions](E7M3-pre-production-backlog.md#kickoff-decisions) and [E7_M3 — Prototype Slice 3 freeze](../decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01). No blocking `AskQuestion` items.
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| JSON naming | camelCase: `reputationGrants`, `factionGateRules`, `factionId`, `minStanding` | E7M2 bundle pattern; backlog kickoff table |
|
||||
| Rep delivery | Via `completionRewardBundle.reputationGrants` (E7.M2 bundle idempotency path) | E7_M3 module doc; NEO-138 applies rep |
|
||||
| Gate site | Quest **accept** only (Slice 3) | Backlog E7M3-06; zone gates deferred to E4.M1 |
|
||||
| Faction roster | Exactly two ids: `prototype_faction_grid_operators`, `prototype_faction_rust_collective` | E7_M3 freeze table |
|
||||
| Quest roster | Five quests: four E7.M1 onboarding + `prototype_quest_grid_contract` | E7_M3 freeze; expands `PROTOTYPE_E7M1_QUEST_IDS` → `PROTOTYPE_E7M3_QUEST_IDS` |
|
||||
| Operator chain | Add Grid Operators **+15** `reputationGrants`; keep existing item + skill XP | E7_M3 freeze |
|
||||
| Grid contract | Prereq operator chain; Grid Operators `minStanding` **15**; objective `inventory_has_item` `survey_drone_kit` ×1; completion Rust Collective **+10** | E7_M3 freeze |
|
||||
| Server loader / stores / HTTP / Godot | **Out of scope** (NEO-134+) | Backlog E7M3-01 scope |
|
||||
| `dotnet test` green | **Minimal roster parity** on existing E7.M1/E7.M2 catalog rules only (see below) | Content JSON change breaks four-quest server freeze without constant sync |
|
||||
|
||||
### Minimal server parity (CI / test green only)
|
||||
|
||||
Adding the fifth quest and updated `prototype_quests.json` will fail server startup tests until `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` includes five ids. This is **not** NEO-134 loader work — only constant + freeze-table sync mirroring NEO-124 → NEO-125:
|
||||
|
||||
- Extend `ExpectedQuestIds` + add `GridContractQuestId` / objective constants.
|
||||
- Add `prototype_quest_grid_contract` to `PrototypeE7M2QuestCatalogRules.ExpectedCompletionBundles` with **empty** `itemGrants` / `skillXpGrants` (rep-only bundle; server `ParseCompletionRewardBundle` ignores `reputationGrants` until NEO-138).
|
||||
- Operator chain JSON may include `reputationGrants`; server E7M2 freeze still compares item + skill XP only (unchanged normalized bundle).
|
||||
|
||||
No new faction loader, no `IFactionDefinitionRegistry`, no gate evaluation.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/faction-def.schema.json` | `FactionDef`: `id`, `displayName`, `minStanding`, `maxStanding`, `neutralStanding` |
|
||||
| `content/schemas/faction-gate-rule.schema.json` | `factionId`, `minStanding` |
|
||||
| `content/schemas/reputation-grant-row.schema.json` | `factionId`, `amount` (signed int) |
|
||||
| `content/factions/prototype_factions.json` | Two frozen factions per E7_M3 freeze |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `content/schemas/quest-def.schema.json` | Optional `factionGateRules` array (`$ref` faction-gate-rule) |
|
||||
| `content/schemas/quest-reward-bundle.schema.json` | Optional `reputationGrants` array (`$ref` reputation-grant-row) |
|
||||
| `content/quests/prototype_quests.json` | Operator chain `reputationGrants`; new `prototype_quest_grid_contract` |
|
||||
| `scripts/validate_content.py` | Faction load/validate; `PROTOTYPE_E7M3_QUEST_IDS` (5); faction cross-ref gates; `PROTOTYPE_E7M3_COMPLETION_BUNDLES` freeze (item + skill + rep); rename/supersede E7M1 four-quest id gate |
|
||||
| `content/README.md` | Document `content/factions/`, new schemas, five-quest roster |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Mark catalog landed when done |
|
||||
| `server/.../PrototypeE7M1QuestCatalogRules.cs` | Five-quest roster + grid-contract constants |
|
||||
| `server/.../PrototypeE7M2QuestCatalogRules.cs` | Grid-contract empty item/skill bundle in freeze |
|
||||
|
||||
## CI gates (`validate_content.py`)
|
||||
|
||||
1. **Faction catalog** — schema validate `content/factions/*_factions.json`; freeze exactly two faction ids + display names + standing band (-100…100, neutral 0).
|
||||
2. **Quest roster** — `PROTOTYPE_E7M3_QUEST_IDS` frozen set of five ids (includes all four E7M1 ids + `prototype_quest_grid_contract`).
|
||||
3. **E7M1 gates retained** — acyclic prerequisites, objective cross-refs, chain terminal token (updated for fifth quest).
|
||||
4. **Faction cross-refs** — every `factionGateRules[].factionId` and `reputationGrants[].factionId` must exist in faction catalog.
|
||||
5. **E7M3 completion bundle freeze** — per-quest expected `itemGrants`, `skillXpGrants`, `reputationGrants` for all five quests (supersedes E7M2 freeze for operator chain + grid contract rep rows; first three gather/craft quests unchanged).
|
||||
6. **Grid contract shape** — prereq operator chain; `factionGateRules` Grid Operators minStanding 15; objective `inventory_has_item` / `survey_drone_kit`.
|
||||
|
||||
## Tests
|
||||
|
||||
| Layer | What |
|
||||
|-------|------|
|
||||
| CI | `python3 scripts/validate_content.py` (existing PR gate) |
|
||||
| Server | Existing quest catalog loader/registry tests — should pass after roster parity constants; no new `[Fact]` unless a regression needs explicit grid-contract coverage |
|
||||
|
||||
Manual QA: **none** (content-only; Godot capstone is NEO-143).
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Schemas (`faction-def`, `faction-gate-rule`, `reputation-grant-row`; extend quest-def + quest-reward-bundle).
|
||||
2. `prototype_factions.json` + quest JSON edits.
|
||||
3. `validate_content.py` gates + bundle freeze tables.
|
||||
4. Minimal server roster/freeze constant sync.
|
||||
5. `content/README.md` + E7_M3 module doc status line.
|
||||
6. Run `python3 scripts/validate_content.py` and `dotnet test` on server project.
|
||||
|
||||
## Out of scope (follow-on tickets)
|
||||
|
||||
| Ticket | Work |
|
||||
|--------|------|
|
||||
| NEO-134 | Server faction catalog fail-fast load |
|
||||
| NEO-135–137 | Faction standing store, delta audit, gate evaluation on accept |
|
||||
| NEO-138 | `RewardRouterOperations` reputation apply + idempotent delivery |
|
||||
| NEO-139–143 | HTTP read, telemetry, Godot HUD, capstone |
|
||||
|
||||
## Client counterpart
|
||||
|
||||
None for E7M3-01. Client faction HUD and capstone: **NEO-142** / **NEO-143**.
|
||||
|
||||
## Blocks
|
||||
|
||||
**NEO-134** (server faction catalog load) is blocked until this catalog + CI lands.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schemas:** `faction-def`, `faction-gate-rule`, `reputation-grant-row`; extended `quest-def` + `quest-reward-bundle`.
|
||||
- **Catalog:** `prototype_factions.json` (two factions); `prototype_quests.json` operator-chain rep + `prototype_quest_grid_contract`.
|
||||
- **CI:** `PROTOTYPE_E7M3_*` gates in `validate_content.py` (roster, faction freeze, bundle freeze, cross-refs, grid-contract shape).
|
||||
- **Server parity:** five-quest `ExpectedQuestIds`, grid-contract E7M2 bundle freeze, schema registry for new `$ref`s.
|
||||
- **Docs:** `content/README.md` E7 Slice 3 paragraph; E7_M3 module CI line.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
# NEO-135 — E7M3-03: Faction standing store + reputation delta audit store
|
||||
|
||||
**Linear:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)
|
||||
**Branch:** `NEO-135-e7m3-faction-standing-reputation-stores`
|
||||
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-03**
|
||||
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
|
||||
**Pattern:** [NEO-116-implementation-plan.md](NEO-116-implementation-plan.md) (in-memory + Postgres when configured); [NEO-126](NEO-126-implementation-plan.md) (append-only audit/event store shape)
|
||||
**Precursor:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `IFactionDefinitionRegistry`, fail-fast faction catalog (**landed on `main`**)
|
||||
**Blocks:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) (ReputationOperations), [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) (gate eval reads standing), [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) (HTTP read)
|
||||
|
||||
## Goal
|
||||
|
||||
Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log. Stores validate faction ids fail-closed at the boundary; missing standing reads as **0** (neutral) clamped to faction min/max.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** Linear AC, [E7M3-03 backlog scope](E7M3-pre-production-backlog.md#e7m3-03--faction-standing-store--reputation-delta-audit-store), kickoff decisions table, and NEO-116/NEO-134 precedents settle:
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| Postgres persistence | **Include when configured** — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116 adopted same |
|
||||
| Store split | **`IFactionStandingStore`** + **`IReputationDeltaStore`** | Backlog in-scope list; audit append-only separate from snapshot |
|
||||
| Public apply API | **Out of scope** — store-level mutation primitives only | Backlog defers **`ReputationOperations`** to E7M3-04 / NEO-136 |
|
||||
| Unknown faction deny | **Structured outcome** with stable reason code at store boundary | Linear AC; kickoff “Fail-closed \| Unknown factionId → deny” |
|
||||
| Default standing | **0** when row missing; **clamp on read** to registry min/max | Backlog + kickoff standing band **-100…100**, neutral **0** |
|
||||
| Clamp on write | **Store primitive clamps** resulting standing before persist | Backlog unit tests “clamp at bounds”; NEO-136 AC inherits same bounds |
|
||||
| Player write gate | **`CanWritePlayer`** on standing store (dev bucket / Postgres `player_position`) | NEO-116 quest store precedent |
|
||||
| Faction validation | Stores inject **`IFactionDefinitionRegistry`** — not raw string trust | Startup catalog + AC “unknown faction id” at store boundary |
|
||||
| Audit row shape | player, faction, delta, resulting standing, source kind, source id, timestamp, row id | Backlog E7M3-03 in-scope bullet |
|
||||
| Source kind (prototype) | **`quest_completion`** string constant only | Backlog example; E7M3-04/06 wire quest id as source id |
|
||||
| DI registration | **`AddFactionStandingStores`** extension separate from catalog | NEO-116 `AddPlayerQuestStateStore` precedent |
|
||||
| HTTP / Godot / gate eval | **Out of scope** | Backlog E7M3-03; NEO-137+ |
|
||||
|
||||
## Scope and out-of-scope
|
||||
|
||||
**In scope (from Linear + backlog):**
|
||||
|
||||
- Snapshot + audit types, id normalization, reason codes.
|
||||
- **`IFactionStandingStore`** + in-memory + Postgres implementations.
|
||||
- **`IReputationDeltaStore`** + in-memory + Postgres implementations.
|
||||
- **`V009__player_faction_standing.sql`**, **`V010__reputation_delta_audit.sql`** when `ConnectionStrings:NeonSprawl` is set.
|
||||
- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores`** wired from **`Program.cs`**.
|
||||
- Unit tests (AAA): default read **0**, read clamp, first standing delta apply, clamp at min/max bounds, unknown faction deny, audit append + query, append-only semantics.
|
||||
- Postgres persistence integration tests (`RequirePostgresFact`) for both stores.
|
||||
- `server/README.md` faction standing + reputation delta store sections.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- **`ReputationOperations.TryApplyDelta`** public orchestration (NEO-136 / E7M3-04).
|
||||
- Quest accept gate evaluation (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot (NEO-142+).
|
||||
- Telemetry hook comments (NEO-141).
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Standing readable per player+faction; deltas append-only and queryable for audit tests.
|
||||
- [x] Unknown faction id returns structured deny at store boundary.
|
||||
- [x] `dotnet test` covers store gates.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Types:** `FactionStandingIds`, reason codes, read/mutation outcomes, `ReputationDeltaRow`, `ReputationDeltaSourceKinds`.
|
||||
- **Stores:** `IFactionStandingStore` / `IReputationDeltaStore`; in-memory + Postgres (`V009`, `V010`) when connection string set.
|
||||
- **DI:** `AddFactionStandingStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
|
||||
- **Tests:** `InMemoryFactionStandingStoreTests`, `InMemoryReputationDeltaStoreTests`, Postgres persistence integration tests; host DI smoke (`768` tests green).
|
||||
- **Docs:** `server/README.md` faction standing + reputation delta store sections.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Types (`Game/Factions/`)
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `FactionStandingIds` | Normalize `playerId` / `factionId`; composite key helper (mirror `QuestProgressIds`) |
|
||||
| `FactionStandingReasonCodes` | `unknown_faction`, `player_not_writable` (`invalid_delta` deferred to NEO-136 `ReputationOperations`) |
|
||||
| `FactionStandingReadOutcome` | `Success`, optional `ReasonCode`, `Standing` (clamped) |
|
||||
| `FactionStandingMutationOutcome` | `Success`, optional `ReasonCode`, `PreviousStanding`, `NewStanding` |
|
||||
| `ReputationDeltaSourceKinds` | `QuestCompletion = "quest_completion"` (prototype constant) |
|
||||
| `ReputationDeltaRow` | Immutable audit row: `Id`, `PlayerId`, `FactionId`, `DeltaAmount`, `ResultingStanding`, `SourceKind`, `SourceId`, `RecordedAt` |
|
||||
|
||||
Standing snapshot is keyed **`(playerId, factionId)` → int`**; no separate row type beyond normalized ids + value.
|
||||
|
||||
### 2. `IFactionStandingStore`
|
||||
|
||||
Inject **`IFactionDefinitionRegistry`** in implementations (not on interface methods).
|
||||
|
||||
- **`bool CanWritePlayer(string playerId)`** — dev bucket or Postgres player row exists.
|
||||
- **`FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)`** — missing row ⇒ **0**; unknown faction ⇒ deny with `unknown_faction`; returned value clamped to faction min/max from registry.
|
||||
- **`FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)`** — validates faction + writable player; reads current (default 0), adds delta, **clamps** to min/max, persists; first write returns success with before/after. Denies unknown faction / non-writable player / empty ids. **Does not append audit** (NEO-136 orchestrates both stores).
|
||||
|
||||
XML remarks: consumed by **`ReputationOperations`** (NEO-136) and gate eval (NEO-137); not HTTP directly.
|
||||
|
||||
### 3. `IReputationDeltaStore`
|
||||
|
||||
- **`bool TryAppend(ReputationDeltaRow row)`** — append-only; returns `false` on duplicate `Id` or invalid row (empty player/faction/id).
|
||||
- **`IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)`** — test/audit read; ordered by `RecordedAt` then `Id` ascending.
|
||||
|
||||
No update/delete APIs in prototype.
|
||||
|
||||
### 4. In-memory implementations
|
||||
|
||||
Mirror NEO-116 / NEO-38 patterns:
|
||||
|
||||
- **`InMemoryFactionStandingStore`** — `ConcurrentDictionary` keyed by composite key; seeds dev player from `GamePositionOptions`; per-key locks.
|
||||
- **`InMemoryReputationDeltaStore`** — append-only list/dictionary by row id; secondary index by player for queries; thread-safe.
|
||||
|
||||
### 5. Postgres implementations
|
||||
|
||||
Mirror NEO-116 quest store:
|
||||
|
||||
- **`PostgresPlayerFactionStandingBootstrap`** + **`V009__player_faction_standing.sql`**
|
||||
- Table `player_faction_standing (player_id FK → player_position, faction_id, standing INT, updated_at)`
|
||||
- PK `(player_id, faction_id)`
|
||||
- **`PostgresReputationDeltaBootstrap`** + **`V010__reputation_delta_audit.sql`**
|
||||
- Table `reputation_delta_audit (id UUID/TEXT PK, player_id FK, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)`
|
||||
- Index on `(player_id, recorded_at)` for audit queries
|
||||
- **`PostgresFactionStandingStore`**, **`PostgresReputationDeltaStore`** — `EnsureSchema` on first use; standing store checks `player_position` exists before write.
|
||||
|
||||
### 6. DI and `Program.cs`
|
||||
|
||||
- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores(configuration)`** — Postgres pair when connection string set; else in-memory pair.
|
||||
- Register **after** `AddFactionDefinitionCatalog` (stores depend on registry at runtime, not catalog singleton).
|
||||
- Place near other player persistence (`AddPlayerQuestStateStore`, `AddRewardDeliveryStore`).
|
||||
- **`InMemoryWebApplicationFactory`**: ensure stores resolve (override only if host tests need isolation — follow existing factory pattern).
|
||||
|
||||
### 7. Tests
|
||||
|
||||
**In-memory (`InMemoryFactionStandingStoreTests`):**
|
||||
|
||||
- Missing row reads **0** for known faction.
|
||||
- Read clamp when stored value exceeds max (direct internal seed or apply overflow scenario).
|
||||
- First `TryApplyStandingDelta` +15 from 0 → 15.
|
||||
- Apply delta that would exceed max clamps to **100**; below min clamps to **-100**.
|
||||
- Unknown faction id → `unknown_faction` deny, standing unchanged.
|
||||
- Non-writable player → deny.
|
||||
|
||||
**In-memory (`InMemoryReputationDeltaStoreTests`):**
|
||||
|
||||
- `TryAppend` success; `GetDeltasForPlayer` returns row.
|
||||
- Second append with new id succeeds; duplicate id returns `false`.
|
||||
- Empty player/faction/id → append deny.
|
||||
|
||||
**Postgres (`FactionStandingPersistenceIntegrationTests`, `ReputationDeltaPersistenceIntegrationTests`):**
|
||||
|
||||
- Write on factory A, read on factory B (`RequirePostgresFact` + harness).
|
||||
|
||||
**Host DI smoke:** resolve both stores from `WebApplicationFactory` (optional one-liner in existing host test file).
|
||||
|
||||
Manual QA: **none** (server infrastructure).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs` | Player/faction id normalization |
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs` | Stable deny codes |
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs` | Read result record |
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs` | Mutation result record |
|
||||
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | Source kind constants |
|
||||
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs` | Audit row type |
|
||||
| `server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs` | Standing store contract |
|
||||
| `server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs` | Audit store contract |
|
||||
| `server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs` | Dev in-memory standing |
|
||||
| `server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs` | Dev in-memory audit |
|
||||
| `server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs` | Migration bootstrap |
|
||||
| `server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs` | Postgres standing |
|
||||
| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs` | Audit migration bootstrap |
|
||||
| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs` | Postgres audit |
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs` | DI registration |
|
||||
| `server/db/migrations/V009__player_faction_standing.sql` | Standing table |
|
||||
| `server/db/migrations/V010__reputation_delta_audit.sql` | Audit table |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs` | Standing AAA tests |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs` | Audit AAA tests |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs` | Postgres standing |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs` | Postgres audit |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `AddFactionStandingStores` after faction catalog |
|
||||
| `server/README.md` | Faction standing + reputation delta store sections |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Register/override faction stores if host tests require it |
|
||||
|
||||
## Tests
|
||||
|
||||
| Layer | What |
|
||||
|-------|------|
|
||||
| `InMemoryFactionStandingStoreTests` | Default 0, read clamp, apply delta, clamp min/max, unknown faction, player gate |
|
||||
| `InMemoryReputationDeltaStoreTests` | Append, query, duplicate-id deny, validation |
|
||||
| `FactionStandingPersistenceIntegrationTests` | Postgres write/read round-trip (`RequirePostgresFact`) |
|
||||
| `ReputationDeltaPersistenceIntegrationTests` | Postgres append/query round-trip (`RequirePostgresFact`) |
|
||||
| CI | Existing `dotnet test` + no content pipeline changes |
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Types, reason codes, ids, store interfaces.
|
||||
2. In-memory standing + audit stores.
|
||||
3. Postgres migrations + bootstrap + Postgres stores.
|
||||
4. DI extension + `Program.cs`.
|
||||
5. Unit tests → Postgres integration tests → `dotnet test`.
|
||||
6. `server/README.md`.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Item | Agent recommendation | Status |
|
||||
|------|---------------------|--------|
|
||||
| Single vs dual migration files | **Two files (V009 standing, V010 audit)** — matches one-table-per-migration precedent (V007 gig, V008 quest) | `adopted` |
|
||||
| Audit row id format | **UUID string** generated by caller (NEO-136); store rejects empty/duplicate | `adopted` |
|
||||
| Standing store exposes `TryApplyStandingDelta` vs absolute set | **Delta primitive only** — matches backlog test wording and NEO-136 apply flow | `adopted` |
|
||||
| Registry on read for unknown faction | **Deny unknown on read and write** — AC “store boundary”; gate eval (NEO-137) always uses catalog factions | `adopted` |
|
||||
|
||||
## Client counterpart
|
||||
|
||||
None for E7M3-03. Client faction HUD: **NEO-142** / capstone **NEO-143**.
|
||||
|
||||
## Blocks
|
||||
|
||||
**NEO-136** (`ReputationOperations`) and **NEO-137** (gate eval) depend on these stores landing first.
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# NEO-136 — E7M3-04: ReputationOperations (apply ReputationDelta)
|
||||
|
||||
**Linear:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)
|
||||
**Branch:** `NEO-136-e7m3-reputation-operations-apply-delta`
|
||||
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-04**
|
||||
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
|
||||
**Pattern:** [NEO-105](NEO-105-implementation-plan.md) / `EncounterCompletionOperations` — static ops + reason codes + compensating rollback; [NEO-127](NEO-127-implementation-plan.md) — orchestrate store writes in order
|
||||
**Precursor:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` (**landed on `main`**)
|
||||
**Blocks:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) (reward bundle rep wiring), [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites)
|
||||
|
||||
## Goal
|
||||
|
||||
Server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation + append-only audit so game code never calls **`IFactionStandingStore.TryApplyStandingDelta`** alone. Apply clamps to faction min/max, records one **`ReputationDelta`** row on success, and returns structured outcomes.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** Linear AC, [E7M3-04 backlog scope](E7M3-pre-production-backlog.md#e7m3-04--reputationoperations-apply-reputationdelta), NEO-135 handoff notes, and NEO-62/NEO-105/NEO-127 precedents settle:
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| Orchestration shape | **Static `ReputationOperations`** with injected stores + `TimeProvider` | `CraftOperations`, `RewardRouterOperations`, `EncounterCompletionOperations` precedent |
|
||||
| Faction validation | **Delegate to `IFactionStandingStore`** (already fail-closed via `IFactionDefinitionRegistry`) | Backlog “validates faction id via registry”; store implements that at boundary — no duplicate registry inject |
|
||||
| `invalid_delta` | **Deny `deltaAmount == 0`** with reason code **`invalid_delta`** at ops layer before store | NEO-135 deferred constant to NEO-136; `SkillProgressionGrantOperations` denies `amount <= 0` |
|
||||
| Audit row id | **Caller supplies non-empty `deltaId`** (UUID string) | NEO-135 adopted decision table: “UUID string generated by caller (NEO-136)” |
|
||||
| Source attribution | **Require non-empty `sourceKind` + `sourceId`**; prototype constant `quest_completion` from `ReputationDeltaSourceKinds` | NEO-135 audit row shape; NEO-138 passes quest id as `sourceId` |
|
||||
| Commit order | **Standing apply first, audit append second** | `IReputationDeltaStore` XML + `server/README.md` Postgres FK remark |
|
||||
| Audit append failure | **Compensating standing rollback** — inverse delta via `TryApplyStandingDelta` when `TryAppend` returns `false` | NEO-62/NEO-69/NEO-105 compensating rollback precedent; avoid standing without audit |
|
||||
| Result type | **New `ReputationApplyOutcome`** (success, reason, before/after standing, optional persisted audit row) | Store outcomes lack ops-level codes (`invalid_delta`, `audit_append_failed`) |
|
||||
| HTTP / quest wiring / Godot | **Out of scope** | Backlog E7M3-04; NEO-138 wires `RewardRouterOperations` |
|
||||
| Bruno / manual QA | **None** — server engine only | Backlog client counterpart: none |
|
||||
|
||||
## Scope and out-of-scope
|
||||
|
||||
**In scope (from Linear + backlog):**
|
||||
|
||||
- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`.
|
||||
- **`ReputationApplyOutcome`**, **`ReputationApplyReasonCodes`** (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough `unknown_faction`, `player_not_writable` from standing store).
|
||||
- Add **`invalid_delta`** to **`FactionStandingReasonCodes`** only if store layer needs it later — **prefer ops-only `ReputationApplyReasonCodes`** to keep store contract unchanged.
|
||||
- Unit tests (AAA): happy path (+15 from 0, audit row queryable), clamp at min/max via ops, unknown faction deny, `deltaAmount == 0` deny, duplicate `deltaId` audit deny with standing unchanged (rollback), empty `deltaId` / source deny.
|
||||
- Host DI smoke: resolve stores + one ops apply through factory services (extend existing faction store host test or dedicated ops test).
|
||||
- `server/README.md` **`ReputationOperations`** section (callers must use ops, not standing store alone).
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- Quest bundle wiring via **`RewardRouterOperations`** (NEO-138 / E7M3-06).
|
||||
- Quest accept gate evaluation (NEO-137).
|
||||
- HTTP GET standing (NEO-139), telemetry hook comments (NEO-141), Godot (NEO-142+).
|
||||
- Postgres-specific integration test for ops (store persistence already covered by NEO-135; ops tests use in-memory stores).
|
||||
|
||||
**Client counterpart:** none (server engine).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Delta apply updates standing once and records audit row.
|
||||
- [x] Standing never exceeds faction min/max after apply.
|
||||
- [x] `dotnet test` covers ops happy path, clamp, unknown faction deny, and invalid-delta deny.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Operations:** `ReputationOperations.TryApplyDelta` — pre-flight validation, standing apply, audit append, compensating rollback on append failure.
|
||||
- **Types:** `ReputationApplyOutcome`, `ReputationApplyReasonCodes` (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`).
|
||||
- **Tests:** `ReputationOperationsTests` — 11 AAA cases including host DI smoke and clamped audit-append rollback (`781` tests green).
|
||||
- **Docs:** `server/README.md` ReputationOperations section; E7.M3 module + alignment register updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Types (`Game/Factions/`)
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `ReputationApplyReasonCodes` | `invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough store codes |
|
||||
| `ReputationApplyOutcome` | `Success`, `ReasonCode`, `PreviousStanding`, `NewStanding`, optional `AuditRow` (`ReputationDeltaRow`) |
|
||||
|
||||
### 2. `ReputationOperations.TryApplyDelta`
|
||||
|
||||
Signature (static, mirror other ops):
|
||||
|
||||
```csharp
|
||||
public static ReputationApplyOutcome TryApplyDelta(
|
||||
string playerId,
|
||||
string factionId,
|
||||
int deltaAmount,
|
||||
string deltaId,
|
||||
string sourceKind,
|
||||
string sourceId,
|
||||
IFactionStandingStore standingStore,
|
||||
IReputationDeltaStore auditStore,
|
||||
TimeProvider timeProvider)
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. Normalize ids via **`FactionStandingIds`**; use normalized values for store calls and audit row.
|
||||
2. **Pre-flight denies** (no store mutation):
|
||||
- `deltaAmount == 0` → `invalid_delta`
|
||||
- empty `deltaId` → `invalid_delta_id`
|
||||
- empty `sourceKind` or `sourceId` → `invalid_source`
|
||||
3. **`standingStore.TryApplyStandingDelta(playerId, factionId, deltaAmount)`** — on deny, return outcome with store **`ReasonCode`** and zeroed standings.
|
||||
4. Build **`ReputationDeltaRow`** with caller `deltaId`, **applied** delta (`newStanding - previousStanding`), `NewStanding` from mutation outcome, normalized ids, **`timeProvider.GetUtcNow()`**.
|
||||
5. **`auditStore.TryAppend(row)`** — on `true`, return success with audit row.
|
||||
6. On append `false`: **compensating rollback** — `standingStore.TryApplyStandingDelta` with **`-appliedDelta`** where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding` (skip when zero); return deny with **`audit_append_failed`**.
|
||||
|
||||
XML remarks: consumed by **`RewardRouterOperations`** (NEO-138) and future admin tools; not HTTP directly. NEO-141 telemetry hook site on successful apply (comment-only in NEO-141).
|
||||
|
||||
### 3. Tests
|
||||
|
||||
**`ReputationOperationsTests`** (in-memory stores + prototype faction registry helper mirroring `InMemoryFactionStandingStoreTests`):
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| `TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath` | +15 from 0; audit row fields; `GetDeltasForPlayer` |
|
||||
| `TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand` | Standing 95 + 50 → 100; audit `ResultingStanding` 100 |
|
||||
| `TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand` | Standing -95 + (-50) → -100 |
|
||||
| `TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason` | No standing change; no audit rows |
|
||||
| `TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero` | `invalid_delta` |
|
||||
| `TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty` | `invalid_delta_id` |
|
||||
| `TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty` | `invalid_source` |
|
||||
| `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` | Pre-seeded duplicate id at standing 95; +50 clamped apply rolls back to 95 |
|
||||
| `TryApplyDelta_ShouldDenyNonWritablePlayer` | `player_not_writable` passthrough |
|
||||
|
||||
**Host DI smoke:** one test resolving stores from `InMemoryWebApplicationFactory` and applying via `ReputationOperations` (can live in same test file).
|
||||
|
||||
Manual QA: **none** (infrastructure-only).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs` | Ops-level stable deny codes including `invalid_delta` |
|
||||
| `server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs` | Structured apply result with optional audit row |
|
||||
| `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs` | `TryApplyDelta` orchestration + compensating rollback |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | AAA unit + host DI smoke tests |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/README.md` | Document `ReputationOperations.TryApplyDelta`, commit order, rollback policy, and “do not call standing store alone for game apply” |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-04 in progress / landed when shipped |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-136 plan + shipped ops when complete |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Action | Coverage |
|
||||
|------|--------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | **Add** | Happy path, clamp min/max, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, host DI smoke |
|
||||
|
||||
No new Postgres integration tests — NEO-135 already covers store persistence; ops layer is store-agnostic.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| Standing rollback fails after audit append deny | Document as prototype invariant violation (same as NEO-62 depletion edge); no cross-store transaction | `deferred` |
|
||||
| Negative `deltaAmount` for future rep loss | **Allow** signed delta at ops layer; content grants are positive only | `adopted` |
|
||||
| Caller-generated `deltaId` vs auto UUID | **Caller supplies id** per NEO-135; NEO-138 can derive from delivery key + faction | `adopted` |
|
||||
| Duplicate `deltaId` on retry after rollback | Append fails → rollback → caller may retry with **new** id; quest idempotency at delivery store prevents double call in NEO-138 | `adopted` |
|
||||
|
||||
**NEO-138** (`RewardRouterOperations` rep grants) and **NEO-141** (telemetry comments) depend on this orchestration landing first.
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
# NEO-137 — E7M3-05: FactionGateOperations + wire into TryAccept
|
||||
|
||||
**Linear:** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)
|
||||
**Branch:** `NEO-137-e7m3-faction-gate-operations-tryaccept`
|
||||
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-05**
|
||||
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
|
||||
**Pattern:** [NEO-136](NEO-136-implementation-plan.md) — static ops + structured outcome + reason codes; [NEO-117](NEO-117-implementation-plan.md) — `QuestStateOperations.TryAccept` deny path + `QuestStateReasonCodes`
|
||||
**Precursors:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `FactionGateRuleRow` on `QuestDefRow`, catalog cross-ref gates; [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore.TryGetStanding` (**landed on `main`**)
|
||||
**Blocks:** [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites — comment-only consolidation)
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD gate feedback (blocked by E7M3-07/E7M3-08)
|
||||
|
||||
## Goal
|
||||
|
||||
Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation. Standing below **`minStanding`** denies with **`faction_gate_blocked`**; all rules satisfied (AND) allows accept to proceed to **`TryActivate`**.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|-------|----------|----------------------|--------|
|
||||
| Bruno smoke scope | Backlog lists deny + success Bruno on **`prototype_quest_grid_contract`**, but rep grants (+15 standing) are not wired until **NEO-138** and there is no HTTP standing seed until **NEO-139**. | **Deny-only Bruno** in NEO-137; **success at threshold** via integration tests seeding standing through **`ReputationOperations`** / store; defer **success Bruno** to **NEO-138** when operator-chain completion grants +15 organically. | **Adopted** — deny-only Bruno now |
|
||||
|
||||
Other decisions settled by Linear AC, backlog, and repo precedent (no additional questions):
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| Gate semantics | **AND** — all rules must pass; empty rule list ⇒ pass | Backlog E7M3-05 |
|
||||
| Comparison | **`standing >= minStanding`** succeeds; strictly below denies | Linear AC “below minStanding” / “succeeds when all rules satisfied”; grid contract **`minStanding` 15** |
|
||||
| Unknown faction at runtime | **Fail-closed via standing store** if ever reached; startup catalog cross-ref (NEO-134) prevents bad content | Backlog “unknown faction … fails closed at startup (E7M3-02) not runtime” + defense-in-depth |
|
||||
| Ops shape | **Static `FactionGateOperations.TryEvaluate`** + **`FactionGateEvaluateOutcome`** | `ReputationOperations`, `EncounterCompletionOperations` precedent |
|
||||
| Wire site | After prerequisite check, **before `TryActivate`** | Backlog E7M3-05 |
|
||||
| Reason code | **`faction_gate_blocked`** on **`QuestStateReasonCodes`** | Backlog + epic telemetry vocabulary |
|
||||
| Telemetry | **Comment-only `faction_gate_blocked` hook** in deny path (NEO-141 README consolidation later) | NEO-136 added `reputation_delta` hook in same slice; backlog E7M3-05 lists hook site |
|
||||
| HTTP / Godot / travel gates | **Out of scope** | Backlog E7M3-05; E4.M1 deferred |
|
||||
|
||||
## Scope and out-of-scope
|
||||
|
||||
**In scope (from Linear + backlog):**
|
||||
|
||||
- **`FactionGateOperations.TryEvaluate`** — AND all rules; read standing via **`IFactionStandingStore.TryGetStanding`** per rule.
|
||||
- **`FactionGateEvaluateOutcome`** (+ optional **`FactionGateEvaluateReasonCodes`** if needed beyond quest-level code).
|
||||
- Wire into **`QuestStateOperations.TryAccept`** after prerequisites, before activation.
|
||||
- **`QuestStateReasonCodes.FactionGateBlocked`** = **`faction_gate_blocked`**.
|
||||
- Pass **`IFactionStandingStore`** through **`TryAccept`**, **`QuestAcceptApi`**, and test helpers.
|
||||
- Integration tests: grid contract denied at standing **14** (prereq complete); succeeds at **15**; quests without gates unaffected.
|
||||
- **Deny-only** Bruno: accept **`prototype_quest_grid_contract`** on fresh dev player ⇒ **`faction_gate_blocked`** (prerequisite completed via prior Bruno steps or documented seq).
|
||||
- Comment-only telemetry hook on gate deny path.
|
||||
- `server/README.md` **`FactionGateOperations`** section.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- **`reputationGrants`** delivery (NEO-138); success Bruno when standing comes from operator-chain completion (NEO-138).
|
||||
- HTTP GET standing (NEO-139), Godot HUD (NEO-142), zone/travel gates (E4.M1).
|
||||
- E9.M1 telemetry ingest (NEO-141 documents hooks only).
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — surfaces **`faction_gate_blocked`** on accept feedback (requires NEO-139/NEO-140 HTTP).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
|
||||
- [x] Accept succeeds when all rules satisfied.
|
||||
- [x] `dotnet test` covers gate deny, threshold success, and no-gate quests.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Operations:** `FactionGateOperations.TryEvaluate` — AND gate eval via standing store reads.
|
||||
- **Types:** `FactionGateEvaluateOutcome`, `FactionGateEvaluateReasonCodes`.
|
||||
- **Wire:** `QuestStateOperations.TryAccept` + `QuestAcceptApi` inject `IFactionStandingStore`; `QuestStateReasonCodes.FactionGateBlocked`.
|
||||
- **Tests:** `FactionGateOperationsTests` (5 cases), grid contract integration in `QuestStateOperationsTests` + `QuestAcceptApiTests` (790 tests green).
|
||||
- **Bruno:** deny-only `Accept grid contract faction gate deny.bru` with `POST …/__dev/quest-fixture` pre-request; success deferred to NEO-138.
|
||||
- **Dev fixture:** `QuestFixtureApi` marks quests completed without reward delivery (Bruno CI prerequisite).
|
||||
- **Docs:** `server/README.md` FactionGateOperations section; E7.M3 module + alignment register updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Types (`Game/Factions/`)
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `FactionGateEvaluateOutcome` | `Success`, optional `ReasonCode`, optional failing rule context (`FactionId`, `MinStanding`, `CurrentStanding`) for telemetry/HUD |
|
||||
| `FactionGateEvaluateReasonCodes` | `gate_blocked` (internal ops code if distinct from quest code); passthrough **`unknown_faction`** from standing store |
|
||||
|
||||
Quest-level deny uses **`QuestStateReasonCodes.FactionGateBlocked`** (`faction_gate_blocked`) — same string epic telemetry expects.
|
||||
|
||||
### 2. `FactionGateOperations.TryEvaluate`
|
||||
|
||||
```csharp
|
||||
public static FactionGateEvaluateOutcome TryEvaluate(
|
||||
string playerId,
|
||||
IReadOnlyList<FactionGateRuleRow> rules,
|
||||
IFactionStandingStore standingStore)
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. Normalize **`playerId`** via **`FactionStandingIds`**.
|
||||
2. **Empty rules** ⇒ success immediately (vacuous AND).
|
||||
3. For each rule in order:
|
||||
- Normalize **`rule.FactionId`**.
|
||||
- **`standingStore.TryGetStanding(playerId, factionId)`** — on store deny, return outcome with store **`ReasonCode`** (defense-in-depth **`unknown_faction`**).
|
||||
- If **`standing < rule.MinStanding`**, return deny with failing rule context and ops reason **`gate_blocked`**.
|
||||
4. All rules pass ⇒ success.
|
||||
|
||||
XML remarks: called from **`QuestStateOperations.TryAccept`** only in prototype; future E4.M1 travel gates may reuse. NEO-141 telemetry hook site on deny return.
|
||||
|
||||
### 3. Wire `QuestStateOperations.TryAccept`
|
||||
|
||||
After prerequisite loop, before **`TryActivate`**:
|
||||
|
||||
```csharp
|
||||
var gate = FactionGateOperations.TryEvaluate(playerId, definition.FactionGateRules, standingStore);
|
||||
if (!gate.Success)
|
||||
{
|
||||
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
|
||||
}
|
||||
```
|
||||
|
||||
Add **`IFactionStandingStore standingStore`** parameter to **`TryAccept`** (and all internal/test call sites). **`QuestAcceptApi`** resolves store from DI.
|
||||
|
||||
**DenyAccept** already threads **`quest_accept_denied`** telemetry hook (NEO-121); gate deny inherits that path with **`reasonCode: faction_gate_blocked`**.
|
||||
|
||||
### 4. Prototype test scenarios
|
||||
|
||||
| Scenario | Setup | Expected |
|
||||
|----------|-------|----------|
|
||||
| Below threshold | Complete **`prototype_quest_operator_chain`** prerequisite only; standing **0** (no rep wiring) | **`faction_gate_blocked`**, no activation |
|
||||
| At threshold | Same prereq + seed **+15** Grid Operators via **`ReputationOperations.TryApplyDelta`** | Accept succeeds, quest active |
|
||||
| No gates | Accept **`prototype_quest_gather_intro`** | Unchanged behavior |
|
||||
| Standing 14 | Seed **+14** via ops | **`faction_gate_blocked`** |
|
||||
|
||||
Constants: **`PrototypeE7M3QuestFactionRules.GridContractQuestId`**, **`GridContractGateFactionId`**, **`GridContractMinStanding`** (15), **`ChainQuestId`** prerequisite.
|
||||
|
||||
### 5. Bruno (deny-only)
|
||||
|
||||
New request under **`bruno/neon-sprawl-server/quest-progress/`** (or subfolder):
|
||||
|
||||
- Complete operator-chain prerequisite path is heavy for Bruno; **minimal deny smoke**: accept grid contract on default dev player with **operator chain completed** but **standing 0** — requires Bruno sequence or docs referencing manual prereq completion.
|
||||
- **Practical seq:** reuse existing accept/complete Bruno steps for operator chain where possible, then accept grid contract ⇒ **`faction_gate_blocked`**. If operator-chain complete Bruno is too heavy, document seq number after gather/chain smokes and add a focused deny request assuming fresh DB with chain already completed via prior collection run.
|
||||
|
||||
Defer **success accept Bruno** to **NEO-138** (operator-chain completion applies +15 rep).
|
||||
|
||||
### 6. Tests
|
||||
|
||||
**`FactionGateOperationsTests`** — unit AAA on in-memory standing store:
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| `TryEvaluate_ShouldPass_WhenRulesEmpty` | Vacuous AND |
|
||||
| `TryEvaluate_ShouldPass_WhenStandingAtMin` | standing **15**, min **15** |
|
||||
| `TryEvaluate_ShouldDeny_WhenStandingBelowMin` | standing **14**, min **15**; failing context |
|
||||
| `TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies` | Invalid faction id (if injectable) |
|
||||
|
||||
**Extend `QuestStateOperationsTests`** (or add **`QuestFactionGateIntegrationTests`**):
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| `TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold` | Prereq complete, standing 0 |
|
||||
| `TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold` | Seed +15, accept succeeds |
|
||||
| `TryAccept_ShouldAcceptGatherIntro_WhenNoGateRules` | Regression |
|
||||
|
||||
**Extend `QuestAcceptApiTests`**: HTTP POST grid contract deny ⇒ **`faction_gate_blocked`** body.
|
||||
|
||||
**Host DI smoke:** resolve **`IFactionStandingStore`** + one gate eval through factory (can live in **`FactionGateOperationsTests`**).
|
||||
|
||||
Manual QA: **none** (server engine; Bruno deny smoke replaces player-visible checklist until NEO-142).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateOutcome.cs` | Structured gate eval result with optional failing rule context |
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateReasonCodes.cs` | Ops-level codes (`gate_blocked`; store passthrough) |
|
||||
| `server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs` | `TryEvaluate` AND logic over standing reads |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs` | AAA unit + host DI smoke |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` | Deny-only Bruno smoke for **`faction_gate_blocked`** |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs` | Add **`FactionGateBlocked`** constant |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Inject standing store; call gate eval before activate; telemetry hook comment on gate deny |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | Resolve **`IFactionStandingStore`**; pass to **`TryAccept`** |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Gate integration tests; update **`TryAccept`** helper signature |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | HTTP deny test for grid contract |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryAccept`** helper if shared |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Update **`TryAccept`** call sites |
|
||||
| `server/README.md` | Document **`FactionGateOperations`**, accept gate order, **`faction_gate_blocked`**, Bruno note |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status: E7M3-05 in progress / landed |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-137 plan |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Action | Coverage |
|
||||
|------|--------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs` | **Add** | Empty rules, at/below threshold, store deny passthrough, host DI |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | **Change** | Grid contract deny/success; no-gate regression |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | **Change** | HTTP **`faction_gate_blocked`** on grid contract accept |
|
||||
|
||||
No new Postgres tests — gate eval is read-only against existing standing store.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| Bruno prereq setup weight | Start with deny request + docs seq; extend collection with chain-complete steps only if CI/manual run needs automation | `adopted` |
|
||||
| Success Bruno timing | **Defer to NEO-138** when rep grants make +15 standing reachable via HTTP quest flow | `adopted` |
|
||||
| Failing rule in HTTP response | **Not in scope** — accept response keeps **`reasonCode` only**; NEO-142/NEO-140 may enrich later | `adopted` |
|
||||
| Gate eval order vs prerequisites | **Prerequisites first** per backlog; player could hit **`prerequisite_incomplete`** before **`faction_gate_blocked`** | `adopted` |
|
||||
|
||||
**NEO-138** (rep grants) and **NEO-141** (telemetry README) follow this gate landing.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Code review — NEO-133 (E7M3-01)
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** Branch `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci` — commits `7183461` … `0948d9d` vs `main`
|
||||
**Base:** `main` (merge-base at `33cfc62`)
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
This branch lands Epic 7 Slice 3 **content contracts** ahead of server faction load: three new JSON schemas (`FactionDef`, `FactionGateRule`, `ReputationGrantRow`), `prototype_factions.json`, quest JSON extensions (`factionGateRules`, `reputationGrants`), a fifth quest (`prototype_quest_grid_contract`), and matching CI gates in `scripts/validate_content.py`. Server changes are intentionally minimal—five-quest roster constants, rep-only grid-contract E7M2 bundle freeze entry, and schema `$ref` registration so startup validation accepts the new quest JSON. Documentation and decomposition tracking (E7.M3 backlog, module register, alignment table) are updated coherently. Risk is low: no runtime faction/rep behavior yet; `validate_content.py` and `dotnet test` both pass locally.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-133-implementation-plan.md` | **Matches** — shipped items reconcile with plan (schemas, catalog, CI, server parity, docs). |
|
||||
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-01 AC checked; client capstone NEO-142/143 documented. |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Slice 3 freeze table and CI line updated. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — Slice 1 freeze table preserved; CI paragraph cross-links E7.M3 five-quest roster. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M3 status In Progress, E7.M2 dependency added. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row added. |
|
||||
| `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 3 backlog link and Godot capstone AC. |
|
||||
| `content/README.md` | **Matches** — factions folder, Slice 3 paragraph. |
|
||||
| `server/README.md` | **N/A for NEO-133** — no faction section yet (planned NEO-134). |
|
||||
|
||||
Register/tracking updates for E7.M3 are appropriately landed in this PR.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E7.M1 module doc vs five-quest CI** — `E7_M1_QuestStateMachine.md` still states CI requires exactly the four Slice 1 quest ids. Add a one-line cross-link to E7.M3 (`PROTOTYPE_E7M3_QUEST_IDS`) so readers do not assume the four-quest gate is still the active roster check.~~ **Done.** CI paragraph cross-links E7.M3 roster expansion.
|
||||
|
||||
2. ~~**Server constant naming** — `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` and `TryGetE7M1GateError` now enforce **five** ids while labels/messages still say “E7M1”. When NEO-134 lands, consider `PrototypeE7M3QuestCatalogRules` (or rename + update error strings) to avoid onboarding confusion.~~ **Done (partial).** Error string now says “prototype quest roster”; class/XML docs note NEO-134 rename. Full type rename deferred to NEO-134.
|
||||
|
||||
3. ~~**Remove dead CI helper** — `_prototype_e7m1_quest_gate` in `scripts/validate_content.py` is no longer called (superseded by `_prototype_e7m3_quest_roster_gate`). Delete or mark deprecated to prevent accidental reintroduction of the four-quest gate.~~ **Done.** Dead helper removed.
|
||||
|
||||
4. ~~**Faction standing band schema** — `faction-def.schema.json` does not constrain `minStanding <= neutralStanding <= maxStanding`. Optional JSON Schema `minimum`/`maximum` cross-field checks (or a CI gate mirroring faction freeze) would catch designer mistakes before NEO-134 server load.~~ **Done.** `_prototype_e7m3_faction_standing_band_gate` enforces ordering on all faction rows.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `PROTOTYPE_E7M1_QUEST_IDS` (four ids) remains beside `PROTOTYPE_E7M3_QUEST_IDS` (five ids); fine for historical Slice 1 reference, but a short comment on E7M1 constant (“Slice 1 roster subset; CI roster gate is E7M3”) would clarify intent.~~ **Done.**
|
||||
- ~~Nit: `QuestDefinitionCatalogLoaderTests.Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract` asserts gather/chain rows but does not assert grid-contract gate/bundle fields; acceptable per plan (“no new Fact unless regression needs it”), but a single assert on grid-contract prereq/gate would cheaply lock the fifth quest shape server-side.~~ **Done.** Asserts grid-contract prereq and rep-only bundle (item/skill empty at server parse layer).
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (passed):
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_content.py
|
||||
dotnet test server/NeonSprawl.Server.Tests
|
||||
```
|
||||
|
||||
Author should confirm PR CI content gate green before merge.
|
||||
|
||||
**Known intentional gap (not a blocker for E7M3-01):** Server `QuestDefRow` / `QuestRewardBundleRow` do not model `factionGateRules` or `reputationGrants`; JSON is schema-validated at load but ignored until NEO-134–NEO-138. Grid contract can be accepted without standing check until NEO-137/NEO-138.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Code review — NEO-135 (E7M3-03)
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** Branch `NEO-135-e7m3-faction-standing-reputation-stores` — commits `08ebfce` … `4fca45b` vs `main`
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
This branch lands **E7M3-03**: durable per-player **`IFactionStandingStore`** snapshots and append-only **`IReputationDeltaStore`** audit rows, with in-memory fallbacks and Postgres implementations (`V009`, `V010`) when `ConnectionStrings:NeonSprawl` is set. Stores inject **`IFactionDefinitionRegistry`** for fail-closed faction validation, default missing standing to **0** with read/write clamping, and gate mutations (and reads) on the dev player bucket / `player_position` row — mirroring NEO-116 quest-store patterns. Test coverage is solid: in-memory AAA for apply/clamp/deny paths, audit ordering/limit/duplicate-id semantics, Postgres persistence round-trips, and host DI smoke. **`766`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or player-facing behavior. Minor gaps: decomposition tracking docs not updated for E7M3-03, and one plan-listed in-memory read-clamp case is only covered via Postgres integration.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-135-implementation-plan.md` | **Matches** — shipped types, stores, migrations, DI, tests, and README reconcile with plan; AC checklist marked complete. Plan types table lists `invalid_delta` reason code not present in code (reserved for NEO-136 — see nits). |
|
||||
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-03 scope (dual stores, clamp, audit append, no public apply API, no client counterpart) aligns with implementation. Backlog AC checkboxes still unchecked (plan doc is authoritative for this branch). |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-03 stores landed (NEO-135). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-03 / NEO-135. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. |
|
||||
| `server/README.md` | **Matches** — new faction standing + reputation delta store sections; correctly defers auditable apply to NEO-136. |
|
||||
| `bruno/neon-sprawl-server/faction-standing/` | **N/A plan item** — health smoke folder added (reasonable host-start verification; not listed in plan). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-03 faction standing + reputation delta stores** landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)). Same pattern as NEO-134 review follow-up.~~ **Done.** Module status + alignment table updated for E7M3-03 / NEO-135.
|
||||
|
||||
2. ~~**In-memory read clamp test** — Plan §7 lists “Read clamp when stored value exceeds max” under `InMemoryFactionStandingStoreTests`. `TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax` exists only in Postgres integration tests. Add an in-memory case (e.g. apply to max then manually seed out-of-band via a second apply + direct dictionary seed is not possible — use apply overflow then verify read clamp, or test via Postgres-only path is insufficient for unit layer). Simplest: after clamp-to-max apply, verify read returns **100**; for true out-of-band raw, mirror Postgres test with reflection/internal test hook **or** document that read-clamp defense is integration-tested only. Prefer a dedicated in-memory test that applies deltas to max then asserts read clamp on subsequent reads at boundary (partial coverage) plus keeping Postgres test for stale DB values.~~ **Done.** Added `TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax` using internal `SeedRawStandingForTests`; Postgres integration test retained for stale DB values.
|
||||
|
||||
3. ~~**Plan types table drift** — `NEO-135-implementation-plan.md` §1 lists `FactionStandingReasonCodes.invalid_delta`; shipped code only has `unknown_faction` and `player_not_writable`. Add a one-line note in the plan that `invalid_delta` is deferred to NEO-136 `ReputationOperations`, or add the constant now without wiring (avoid dead code — note-only is fine).~~ **Done.** Plan types table notes `invalid_delta` deferred to NEO-136.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `PostgresReputationDeltaStore.TryAppend` relies on FK to `player_position`; a row for a non-existent player throws rather than returning `false`. Acceptable if NEO-136 always applies standing first; worth a one-line remark in README or interface XML for orchestrators.~~ **Done.** Remark added to `IReputationDeltaStore.TryAppend` XML and `server/README.md`.
|
||||
- Nit: Host DI smoke (`Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds`) lives in `InMemoryFactionStandingStoreTests.cs` rather than a host-focused test file — fine per plan “optional one-liner.”
|
||||
- ~~Nit: `FactionStandingPersistenceIntegrationTests` places `Assert.True(outcome.Success)` inside the Act block for the first factory write — minor AAA deviation; assertions could move entirely to Assert phase.~~ **Done.**
|
||||
- ~~Nit: Empty `factionId` on read/write returns `player_not_writable` rather than `unknown_faction` — consistent with empty-id handling elsewhere, but slightly imprecise reason code.~~ **Done.** Empty faction id now returns `unknown_faction`.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (passed):
|
||||
|
||||
```bash
|
||||
dotnet test server/NeonSprawl.Server.Tests
|
||||
```
|
||||
|
||||
Author should confirm PR CI green before merge. With Postgres configured locally, `RequirePostgresFact` integration tests should also pass (`ConnectionStrings__NeonSprawl`). Bruno smoke (`bruno/neon-sprawl-server/faction-standing/`) is appropriate — no manual QA doc required per plan (infrastructure-only).
|
||||
|
||||
**Intentional follow-on (not blockers):** `ReputationOperations` orchestration (NEO-136), gate eval reads (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot HUD (NEO-142+).
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Code review — NEO-136 (E7M3-04)
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** Branch `NEO-136-e7m3-reputation-operations-apply-delta` — commits `e9abc8b` … `536f635` vs `main`
|
||||
**Follow-up:** Review findings addressed in follow-up commit (applied-delta rollback, ops id normalization, AAA nit).
|
||||
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
This branch lands **E7M3-04**: server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation via **`IFactionStandingStore`**, append-only audit via **`IReputationDeltaStore`**, and compensating standing rollback when audit append fails. Supporting types **`ReputationApplyOutcome`** and **`ReputationApplyReasonCodes`** cover ops-level pre-flight denies and store passthrough. Ten AAA unit tests plus host DI smoke exercise happy path, clamp, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, and factory resolution. **`780`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or Godot wiring (NEO-138+). One non-blocking correctness gap: compensating rollback uses `-deltaAmount` rather than the **actual applied delta** when clamping occurred, which can leave wrong standing on rare audit-append failure after a clamped apply.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-136-implementation-plan.md` | **Matches** — shipped ops, types, tests, README, module/alignment updates reconcile with plan; AC checklist marked complete. Plan flow step 1 cites explicit `FactionStandingIds` normalization at ops layer; implementation delegates player/faction normalization to stores (see Suggestions). |
|
||||
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-04 scope (auditable apply orchestration, no client counterpart, no HTTP) aligns with implementation. |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-04 `ReputationOperations` landed (NEO-136). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-04 / NEO-136. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. |
|
||||
| `server/README.md` | **Matches** — new ReputationOperations section documents commit order, rollback policy, pre-flight denies, and “do not call standing store alone.” |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Compensating rollback delta when clamping** — On audit append failure, rollback uses `-deltaAmount`:~~ **Done.** Rollback now uses `-appliedDelta` where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding`; audit row records applied delta. Test `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` added.
|
||||
|
||||
2. ~~**Ops-layer id normalization (plan alignment)** — Plan §2 step 1 says normalize via **`FactionStandingIds`** before store calls.~~ **Done.** `TryApplyDelta` normalizes player/faction ids at the ops boundary; audit row built from normalized values.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath`** — `auditStore.GetDeltasForPlayer(PlayerId)` runs in the **Act** phase; per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), verification reads belong in **Assert**.~~ **Done.** `GetDeltasForPlayer` moved to Assert.
|
||||
- Nit: **`ReputationApplyOutcome`** on store-boundary deny returns standings from the mutation outcome (good); pre-flight **`Deny()`** zeroes standings — callers must not treat `0` as “neutral read” on `invalid_delta` / `invalid_source` denies (documented implicitly by reason codes).
|
||||
- Nit: Telemetry hook comment for NEO-141 is correctly placed on the success path only — no action needed.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (passed):
|
||||
|
||||
```bash
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ReputationOperationsTests"
|
||||
dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Author should confirm PR CI green before merge. No manual QA doc required (server engine only per plan). **Intentional follow-on:** reward bundle rep wiring (NEO-138), quest accept gate eval (NEO-137), HTTP GET standing (NEO-139), Godot HUD (NEO-142+).
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Code review — NEO-137 (E7M3-05)
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** Branch `NEO-137-e7m3-faction-gate-operations-tryaccept` — commits `1c3d075` … `0241cc3` vs `main`
|
||||
**Base:** `main`
|
||||
**Follow-up:** Review findings addressed in follow-up commits (Bruno CI fix via quest fixture API, multi-rule test, README, nits).
|
||||
|
||||
## Verdict
|
||||
|
||||
~~**Request changes**~~ **Approved after follow-up**
|
||||
|
||||
## Summary
|
||||
|
||||
This branch lands **E7M3-05**: **`FactionGateOperations.TryEvaluate`** evaluates quest **`FactionGateRule`** rows with vacuous-AND semantics (`standing >= minStanding`), wired into **`QuestStateOperations.TryAccept`** after prerequisite checks and before activation. Quest-level denies surface **`faction_gate_blocked`** via **`QuestStateReasonCodes.FactionGateBlocked`**; ops-level **`FactionGateEvaluateOutcome`** retains failing-rule context for future telemetry/HUD. **`IFactionStandingStore`** threads through **`QuestAcceptApi`** and test helpers. Coverage is solid: five unit tests, three integration tests on grid contract (deny at 0/14, success at 15), HTTP deny test, and host DI smoke. **`790`** server tests pass locally. Core server logic matches the plan and NEO-136 ops precedent. ~~One **blocking** gap: the new Bruno deny smoke runs in CI without completing **`prototype_quest_operator_chain`**, so the collection will assert the wrong **`reasonCode`** (`prerequisite_incomplete` instead of **`faction_gate_blocked`**).~~ **Done.** Bruno pre-request calls **`POST …/__dev/quest-fixture`** to mark operator chain completed without rep delivery; CI enables **`Game:EnableQuestFixtureApi`**.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-137-implementation-plan.md` | **Matches** — ops, types, wire site, reason code, tests, README, module/alignment updates; AC checklist marked complete; success Bruno deferred to NEO-138 per kickoff. |
|
||||
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-05 scope (quest accept gate eval, no HTTP standing read, no client counterpart). |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-05 `FactionGateOperations` landed (NEO-137). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-05 / NEO-137. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionGateRule` contract. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — `TryAccept` gate hook site behavior aligns with module responsibilities. |
|
||||
| `server/README.md` | **Matches** — new FactionGateOperations section + quest accept POST table includes `faction_gate_blocked`. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
1. ~~**Bruno CI will fail on faction-gate deny smoke.** `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` (seq **10**) asserts `reasonCode === "faction_gate_blocked"`, but the quest-progress collection never completes **`prototype_quest_operator_chain`** before that request (prior steps only accept/deny gather/refine through seq **8**). **`TryAccept`** checks prerequisites before gate eval, so a fresh dev player receives **`prerequisite_incomplete`**, not **`faction_gate_blocked`**. `.github/workflows/dotnet.yml` runs the full Bruno collection with **`--bail`** on every push touching `bruno/neon-sprawl-server/**`. Fix options (pick one): add prerequisite Bruno steps (accept + complete operator chain, or a dev fixture seed if/when available), add a **`script:pre-request`** that marks the chain completed the way integration tests do, or defer/remove this request from the CI collection until NEO-138 makes the spine reachable. The `.bru` **docs** block acknowledges the prerequisite but the collection does not implement it.~~ **Done.** Added **`QuestFixtureApi`** (`POST …/__dev/quest-fixture`) + Bruno pre-request; CI **`Game:EnableQuestFixtureApi`**.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Multi-rule AND unit test.** Prototype grid contract has one gate today; add a **`TryEvaluate`** test with two rules (one pass, one fail) to lock AND semantics if content adds multi-faction gates before E4.M1 travel gates reuse the ops.~~ **Done.** `TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList` in `FactionGateOperationsTests.cs`.
|
||||
2. ~~**Bruno seq documentation in README.** `server/README.md` notes success Bruno is deferred to NEO-138; add one line that deny Bruno requires operator-chain-complete setup (or list the intended seq after prerequisite steps land) so manual runs do not surprise operators.~~ **Done.** FactionGateOperations README section documents seq **10** + quest-fixture pre-request.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`TryAccept`** maps all gate failures to **`faction_gate_blocked`**, discarding ops-level **`unknown_faction`** from the standing store — intentional per plan (quest-level code only in HTTP), but worth a one-line XML remark on the gate deny branch if NEO-141 telemetry needs to distinguish store vs threshold denies later.~~ **Done.** Comment on gate deny branch in `QuestStateOperations.cs`.
|
||||
- ~~Nit: **`FactionGateOperationsTests`** host DI smoke asserts **`gate_blocked`** at standing 0 without seeding standing — correct behavior, but a brief comment that missing standing reads as **0** would aid future readers.~~ **Done.** Arrange comment in host DI smoke test.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Full server suite (author reported 790 green)
|
||||
dotnet test NeonSprawl.sln
|
||||
|
||||
# Faction-gate-focused filter
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~FactionGate|FullyQualifiedName~GridContract"
|
||||
|
||||
# Bruno (will fail on seq 10 until prerequisite setup is added)
|
||||
cd bruno/neon-sprawl-server
|
||||
npx --yes @usebruno/cli@3.3.0 run quest-progress --env Local --sandbox=developer --bail
|
||||
```
|
||||
|
||||
Manual: after fixing Bruno prerequisites, run **`Accept grid contract faction gate deny.bru`** only when **`prototype_quest_operator_chain`** is **completed** and Grid Operators standing is **< 15**.
|
||||
|
|
@ -15,7 +15,9 @@ Validates:
|
|||
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
|
||||
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
|
||||
- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112);
|
||||
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124)
|
||||
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124);
|
||||
optional factionGateRules + reputationGrants (NEO-133)
|
||||
- faction catalogs: content/factions/*_factions.json rows vs content/schemas/faction-def.schema.json (NEO-133)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -46,6 +48,9 @@ QUEST_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json"
|
|||
QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json"
|
||||
QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json"
|
||||
QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json"
|
||||
FACTION_DEF_SCHEMA = REPO_ROOT / "content/schemas/faction-def.schema.json"
|
||||
FACTION_GATE_RULE_SCHEMA = REPO_ROOT / "content/schemas/faction-gate-rule.schema.json"
|
||||
REPUTATION_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reputation-grant-row.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
|
|
@ -56,6 +61,7 @@ NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
|
|||
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
|
||||
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
|
||||
QUESTS_DIR = REPO_ROOT / "content/quests"
|
||||
FACTIONS_DIR = REPO_ROOT / "content/factions"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -160,8 +166,9 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
|
|||
"contract_handoff_token": 1,
|
||||
}
|
||||
|
||||
# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes.
|
||||
# Keep in sync with E7.M1 freeze table and future NEO-113 server loader.
|
||||
# Epic 7 Slice 1 prototype lock (NEO-112): Slice 1 onboarding quest ids (historical subset).
|
||||
# Active CI roster gate is PROTOTYPE_E7M3_QUEST_IDS (five quests, NEO-133).
|
||||
# Keep in sync with E7.M1 freeze table; server ExpectedQuestIds expanded for E7M3.
|
||||
PROTOTYPE_E7M1_QUEST_IDS = frozenset(
|
||||
{
|
||||
"prototype_quest_gather_intro",
|
||||
|
|
@ -195,6 +202,74 @@ PROTOTYPE_E7M2_COMPLETION_BUNDLES: dict[str, dict] = {
|
|||
},
|
||||
}
|
||||
|
||||
# Epic 7 Slice 3 prototype lock (NEO-133): five-quest roster + faction catalog.
|
||||
# Keep in sync with E7.M3 freeze table and future NEO-134 server loader.
|
||||
PROTOTYPE_E7M3_FACTION_IDS = frozenset(
|
||||
{
|
||||
"prototype_faction_grid_operators",
|
||||
"prototype_faction_rust_collective",
|
||||
}
|
||||
)
|
||||
PROTOTYPE_E7M3_FACTION_FREEZE: dict[str, dict] = {
|
||||
"prototype_faction_grid_operators": {
|
||||
"displayName": "Grid Operators",
|
||||
"minStanding": -100,
|
||||
"maxStanding": 100,
|
||||
"neutralStanding": 0,
|
||||
},
|
||||
"prototype_faction_rust_collective": {
|
||||
"displayName": "Rust Collective",
|
||||
"minStanding": -100,
|
||||
"maxStanding": 100,
|
||||
"neutralStanding": 0,
|
||||
},
|
||||
}
|
||||
PROTOTYPE_E7M3_QUEST_IDS = frozenset(
|
||||
{
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_grid_contract",
|
||||
}
|
||||
)
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID = "prototype_quest_grid_contract"
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID = "prototype_quest_operator_chain"
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID = "prototype_faction_grid_operators"
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING = 15
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID = "survey_drone_kit"
|
||||
PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = {
|
||||
"prototype_quest_gather_intro": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
"prototype_quest_refine_intro": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [{"skillId": "refine", "amount": 25}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
"prototype_quest_combat_intro": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
"prototype_quest_operator_chain": {
|
||||
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 50}],
|
||||
"reputationGrants": [
|
||||
{"factionId": "prototype_faction_grid_operators", "amount": 15}
|
||||
],
|
||||
},
|
||||
"prototype_quest_grid_contract": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [],
|
||||
"reputationGrants": [
|
||||
{"factionId": "prototype_faction_rust_collective", "amount": 10}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_completion_bundle(bundle: dict) -> dict:
|
||||
"""Normalize grant rows for stable freeze-table comparison."""
|
||||
|
|
@ -224,6 +299,23 @@ def _normalize_completion_bundle(bundle: dict) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _normalize_e7m3_completion_bundle(bundle: dict) -> dict:
|
||||
"""Normalize item, skill XP, and reputation grant rows for E7M3 freeze-table comparison."""
|
||||
normalized = _normalize_completion_bundle(bundle)
|
||||
reputation_grants = bundle.get("reputationGrants")
|
||||
normalized["reputationGrants"] = sorted(
|
||||
[
|
||||
{"factionId": grant["factionId"], "amount": grant["amount"]}
|
||||
for grant in (reputation_grants if isinstance(reputation_grants, list) else [])
|
||||
if isinstance(grant, dict)
|
||||
and isinstance(grant.get("factionId"), str)
|
||||
and isinstance(grant.get("amount"), int)
|
||||
],
|
||||
key=lambda grant: (grant["factionId"], grant["amount"]),
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
|
|
@ -895,13 +987,15 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str
|
|||
|
||||
|
||||
def _quest_def_validator() -> Draft202012Validator:
|
||||
"""Build a validator that resolves quest-def $ref to step, objective, and bundle schemas."""
|
||||
"""Build a validator that resolves quest-def $ref to step, objective, bundle, and gate schemas."""
|
||||
objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8"))
|
||||
grant_row_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
faction_gate_rule_schema = json.loads(FACTION_GATE_RULE_SCHEMA.read_text(encoding="utf-8"))
|
||||
reputation_grant_row_schema = json.loads(REPUTATION_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
registry = Registry().with_resources(
|
||||
[
|
||||
(def_schema["$id"], Resource.from_contents(def_schema)),
|
||||
|
|
@ -910,11 +1004,62 @@ def _quest_def_validator() -> Draft202012Validator:
|
|||
(bundle_schema["$id"], Resource.from_contents(bundle_schema)),
|
||||
(skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)),
|
||||
(grant_row_schema["$id"], Resource.from_contents(grant_row_schema)),
|
||||
(faction_gate_rule_schema["$id"], Resource.from_contents(faction_gate_rule_schema)),
|
||||
(reputation_grant_row_schema["$id"], Resource.from_contents(reputation_grant_row_schema)),
|
||||
]
|
||||
)
|
||||
return Draft202012Validator(def_schema, registry=registry)
|
||||
|
||||
|
||||
def _validate_faction_catalogs(
|
||||
*,
|
||||
faction_files: list[Path],
|
||||
faction_validator: Draft202012Validator,
|
||||
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||
"""Validate faction JSON files. Returns (error_count, seen_ids, id_to_row)."""
|
||||
errors = 0
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_row: dict[str, dict] = {}
|
||||
|
||||
for path in faction_files:
|
||||
rel = str(path.relative_to(REPO_ROOT))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
schema_version = data.get("schemaVersion")
|
||||
if schema_version != 1:
|
||||
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
factions = data.get("factions")
|
||||
if not isinstance(factions, list):
|
||||
print(f"error: {rel}: expected top-level 'factions' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(factions):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: factions[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(faction_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} factions[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
fid = row.get("id")
|
||||
if isinstance(fid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(fid)
|
||||
if prev:
|
||||
print(f"error: duplicate faction id {fid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[fid] = rel
|
||||
id_to_row[fid] = row
|
||||
|
||||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _validate_quest_catalogs(
|
||||
*,
|
||||
quest_files: list[Path],
|
||||
|
|
@ -998,13 +1143,13 @@ def _validate_quest_catalogs(
|
|||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M1 quest contract fails, else None."""
|
||||
def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M3 five-quest roster fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E7M1_QUEST_IDS:
|
||||
if ids != PROTOTYPE_E7M3_QUEST_IDS:
|
||||
return (
|
||||
"error: prototype E7M1 expects exactly quest ids "
|
||||
f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}"
|
||||
"error: prototype E7M3 expects exactly quest ids "
|
||||
f"{sorted(PROTOTYPE_E7M3_QUEST_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
@ -1132,7 +1277,7 @@ def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | Non
|
|||
|
||||
def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if a frozen quest lacks completionRewardBundle, else None."""
|
||||
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS):
|
||||
for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS):
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {qid!r}"
|
||||
|
|
@ -1166,7 +1311,7 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
|
|||
skill_id_to_allowed_kinds: dict[str, list[str]],
|
||||
) -> str | None:
|
||||
"""Return a human-readable error if bundle refs fail cross-checks, else None."""
|
||||
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS):
|
||||
for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS):
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
|
@ -1206,6 +1351,165 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
|
|||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_roster_gate(faction_seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M3 faction roster fails, else None."""
|
||||
ids = frozenset(faction_seen_ids.keys())
|
||||
if ids != PROTOTYPE_E7M3_FACTION_IDS:
|
||||
return (
|
||||
"error: prototype E7M3 expects exactly faction ids "
|
||||
f"{sorted(PROTOTYPE_E7M3_FACTION_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if faction rows diverge from E7M3 freeze table, else None."""
|
||||
for fid, expected in PROTOTYPE_E7M3_FACTION_FREEZE.items():
|
||||
row = id_to_row.get(fid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing faction {fid!r}"
|
||||
for key, expected_value in expected.items():
|
||||
if row.get(key) != expected_value:
|
||||
return (
|
||||
f"error: faction {fid!r} must match E7M3 freeze table "
|
||||
f"(expected {key}={expected_value!r}, got {row.get(key)!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_standing_band_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if faction standing band ordering fails, else None."""
|
||||
for fid, row in id_to_row.items():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
min_standing = row.get("minStanding")
|
||||
max_standing = row.get("maxStanding")
|
||||
neutral_standing = row.get("neutralStanding")
|
||||
if not all(isinstance(value, int) for value in (min_standing, max_standing, neutral_standing)):
|
||||
continue
|
||||
if min_standing > neutral_standing or neutral_standing > max_standing:
|
||||
return (
|
||||
f"error: faction {fid!r} requires minStanding <= neutralStanding <= maxStanding "
|
||||
f"(got min={min_standing!r}, neutral={neutral_standing!r}, max={max_standing!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_cross_ref_gate(
|
||||
id_to_row: dict[str, dict],
|
||||
faction_seen_ids: dict[str, str],
|
||||
) -> str | None:
|
||||
"""Return a human-readable error if quest faction refs fail, else None."""
|
||||
known_factions = frozenset(faction_seen_ids.keys())
|
||||
for qid, row in id_to_row.items():
|
||||
gate_rules = row.get("factionGateRules")
|
||||
if isinstance(gate_rules, list):
|
||||
for gi, rule in enumerate(gate_rules):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
faction_id = rule.get("factionId")
|
||||
if isinstance(faction_id, str) and faction_id not in known_factions:
|
||||
return (
|
||||
f"error: quest {qid!r} factionGateRules[{gi}]: factionId {faction_id!r} "
|
||||
"is not in the frozen prototype faction catalog"
|
||||
)
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if isinstance(bundle, dict):
|
||||
reputation_grants = bundle.get("reputationGrants")
|
||||
if isinstance(reputation_grants, list):
|
||||
for gi, grant in enumerate(reputation_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
faction_id = grant.get("factionId")
|
||||
if isinstance(faction_id, str) and faction_id not in known_factions:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle.reputationGrants[{gi}]: "
|
||||
f"factionId {faction_id!r} is not in the frozen prototype faction catalog"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_completion_bundle_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if bundle contents diverge from E7M3 freeze table, else None."""
|
||||
for qid, expected in PROTOTYPE_E7M3_COMPLETION_BUNDLES.items():
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {qid!r}"
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
return f"error: quest {qid!r} must include completionRewardBundle object"
|
||||
actual = _normalize_e7m3_completion_bundle(bundle)
|
||||
expected_normalized = _normalize_e7m3_completion_bundle(expected)
|
||||
if actual != expected_normalized:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle must match E7M3 freeze table "
|
||||
f"(expected {expected_normalized!r}, got {actual!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_grid_contract_shape_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if grid contract quest shape fails, else None."""
|
||||
row = id_to_row.get(PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r}"
|
||||
|
||||
prereqs = row.get("prerequisiteQuestIds")
|
||||
if prereqs != [PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} prerequisiteQuestIds must be "
|
||||
f"{[PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]!r}, got {prereqs!r}"
|
||||
)
|
||||
|
||||
gate_rules = row.get("factionGateRules")
|
||||
expected_gate = [
|
||||
{
|
||||
"factionId": PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID,
|
||||
"minStanding": PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING,
|
||||
}
|
||||
]
|
||||
if gate_rules != expected_gate:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} factionGateRules must be "
|
||||
f"{expected_gate!r}, got {gate_rules!r}"
|
||||
)
|
||||
|
||||
steps = row.get("steps")
|
||||
if not isinstance(steps, list) or len(steps) != 1:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one step"
|
||||
)
|
||||
step = steps[0]
|
||||
if not isinstance(step, dict):
|
||||
return f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} step must be an object"
|
||||
objectives = step.get("objectives")
|
||||
if not isinstance(objectives, list) or len(objectives) != 1:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one objective"
|
||||
)
|
||||
obj = objectives[0]
|
||||
if not isinstance(obj, dict):
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective must be an object"
|
||||
)
|
||||
if obj.get("kind") != "inventory_has_item":
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective kind must be "
|
||||
"'inventory_has_item'"
|
||||
)
|
||||
if obj.get("itemId") != PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective itemId must be "
|
||||
f"{PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID!r}, got {obj.get('itemId')!r}"
|
||||
)
|
||||
if obj.get("quantity") != 1:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective quantity must be 1, "
|
||||
f"got {obj.get('quantity')!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||
if len(track_skill_ids) != 1:
|
||||
|
|
@ -1556,6 +1860,15 @@ def main() -> int:
|
|||
if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file():
|
||||
print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not FACTION_DEF_SCHEMA.is_file():
|
||||
print(f"error: missing schema {FACTION_DEF_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not FACTION_GATE_RULE_SCHEMA.is_file():
|
||||
print(f"error: missing schema {FACTION_GATE_RULE_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not REPUTATION_GRANT_ROW_SCHEMA.is_file():
|
||||
print(f"error: missing schema {REPUTATION_GRANT_ROW_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -1578,6 +1891,8 @@ def main() -> int:
|
|||
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
encounter_validator = Draft202012Validator(encounter_schema)
|
||||
quest_validator = _quest_def_validator()
|
||||
faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
faction_validator = Draft202012Validator(faction_schema)
|
||||
|
||||
if not SKILLS_DIR.is_dir():
|
||||
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
|
||||
|
|
@ -1679,6 +1994,15 @@ def main() -> int:
|
|||
print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not FACTIONS_DIR.is_dir():
|
||||
print(f"error: missing directory {FACTIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
faction_files = sorted(FACTIONS_DIR.glob("*_factions.json"))
|
||||
if not faction_files:
|
||||
print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
skill_id_to_allowed_kinds: dict[str, list[str]] = {}
|
||||
|
|
@ -1950,6 +2274,29 @@ def main() -> int:
|
|||
print(e5m3_encounter_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
faction_errors, faction_seen_ids, faction_id_to_row = _validate_faction_catalogs(
|
||||
faction_files=faction_files,
|
||||
faction_validator=faction_validator,
|
||||
)
|
||||
if faction_errors:
|
||||
print(f"content validation failed with {faction_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_roster_err = _prototype_e7m3_faction_roster_gate(faction_seen_ids)
|
||||
if e7m3_faction_roster_err:
|
||||
print(e7m3_faction_roster_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_freeze_err = _prototype_e7m3_faction_freeze_gate(faction_id_to_row)
|
||||
if e7m3_faction_freeze_err:
|
||||
print(e7m3_faction_freeze_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_band_err = _prototype_e7m3_faction_standing_band_gate(faction_id_to_row)
|
||||
if e7m3_faction_band_err:
|
||||
print(e7m3_faction_band_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs(
|
||||
quest_files=quest_files,
|
||||
quest_validator=quest_validator,
|
||||
|
|
@ -1958,9 +2305,9 @@ def main() -> int:
|
|||
print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids)
|
||||
if e7m1_quest_err:
|
||||
print(e7m1_quest_err, file=sys.stderr)
|
||||
e7m3_quest_roster_err = _prototype_e7m3_quest_roster_gate(quest_seen_ids)
|
||||
if e7m3_quest_roster_err:
|
||||
print(e7m3_quest_roster_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row)
|
||||
|
|
@ -1996,6 +2343,24 @@ def main() -> int:
|
|||
print(e7m2_bundle_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_cross_ref_err = _prototype_e7m3_faction_cross_ref_gate(
|
||||
quest_id_to_row,
|
||||
faction_seen_ids,
|
||||
)
|
||||
if e7m3_faction_cross_ref_err:
|
||||
print(e7m3_faction_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_bundle_freeze_err = _prototype_e7m3_completion_bundle_freeze_gate(quest_id_to_row)
|
||||
if e7m3_bundle_freeze_err:
|
||||
print(e7m3_bundle_freeze_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_grid_contract_err = _prototype_e7m3_grid_contract_shape_gate(quest_id_to_row)
|
||||
if e7m3_grid_contract_err:
|
||||
print(e7m3_grid_contract_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"content OK: "
|
||||
f"{len(skill_files)} skill catalog file(s), "
|
||||
|
|
@ -2009,6 +2374,7 @@ def main() -> int:
|
|||
f"{len(npc_behavior_files)} npc behavior catalog file(s), "
|
||||
f"{len(reward_table_files)} reward table catalog file(s), "
|
||||
f"{len(encounter_files)} encounter catalog file(s), "
|
||||
f"{len(faction_files)} faction catalog file(s), "
|
||||
f"{len(quest_files)} quest catalog file(s), "
|
||||
f"{len(seen_ids)} unique skill id(s), "
|
||||
f"{len(item_seen_ids)} unique item id(s), "
|
||||
|
|
@ -2018,6 +2384,7 @@ def main() -> int:
|
|||
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
|
||||
f"{len(reward_table_seen_ids)} unique reward table id(s), "
|
||||
f"{len(encounter_seen_ids)} unique encounter id(s), "
|
||||
f"{len(faction_seen_ids)} unique faction id(s), "
|
||||
f"{len(quest_seen_ids)} unique quest id(s), "
|
||||
f"{len(track_skill_ids)} mastery track(s)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(ex);
|
||||
Assert.Contains("must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, FactionDefRow>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, FactionDefRow> 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<string, FactionDefRow>(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<string, FactionDefRow>(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<string, FactionDefRow>(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<string, FactionDefRow>(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<IFactionDefinitionRegistry>();
|
||||
var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var grid);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(grid);
|
||||
Assert.Equal("Grid Operators", grid!.DisplayName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
public sealed class FactionGateOperationsTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||
private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding;
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldPass_WhenRulesEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, [], standingStore);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
Assert.Null(outcome.FailingFactionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldPass_WhenStandingAtMin()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
|
||||
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldDeny_WhenStandingBelowMin()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding - 1).Success);
|
||||
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
|
||||
Assert.Equal(GridFactionId, outcome.FailingFactionId);
|
||||
Assert.Equal(GridMinStanding, outcome.RequiredMinStanding);
|
||||
Assert.Equal(GridMinStanding - 1, outcome.CurrentStanding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
const string rustFactionId = "prototype_faction_rust_collective";
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
|
||||
var rules = new[]
|
||||
{
|
||||
new FactionGateRuleRow(GridFactionId, GridMinStanding),
|
||||
new FactionGateRuleRow(rustFactionId, 1),
|
||||
};
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
|
||||
Assert.Equal(rustFactionId, outcome.FailingFactionId);
|
||||
Assert.Equal(1, outcome.RequiredMinStanding);
|
||||
Assert.Equal(0, outcome.CurrentStanding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
const string unknownFactionId = "prototype_faction_missing";
|
||||
var rules = new[] { new FactionGateRuleRow(unknownFactionId, 0) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode);
|
||||
Assert.Equal(unknownFactionId, outcome.FailingFactionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveStandingStoreForGateEval_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange — missing standing row reads as 0 (neutral), below grid contract minStanding 15.
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
|
||||
// Act
|
||||
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
private static InMemoryFactionStandingStore CreateStandingStore()
|
||||
{
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
return new InMemoryFactionStandingStore(options, registry);
|
||||
}
|
||||
|
||||
private static FactionDefinitionRegistry CreatePrototypeRegistry()
|
||||
{
|
||||
var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||
var rustFactionId = "prototype_faction_rust_collective";
|
||||
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId],
|
||||
[rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId],
|
||||
};
|
||||
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||
return new FactionDefinitionRegistry(catalog);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||
{
|
||||
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task ApplyDeltaThenGetAcrossNewFactory_ShouldPersistStanding()
|
||||
{
|
||||
// Arrange
|
||||
await ResetFactionStandingTableAsync();
|
||||
|
||||
// Act — write through first host
|
||||
FactionStandingMutationOutcome writeOutcome;
|
||||
using (var firstScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var store = firstScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||
writeOutcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15);
|
||||
}
|
||||
|
||||
FactionStandingReadOutcome readBack;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||
readBack = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(writeOutcome.Success);
|
||||
Assert.Equal(15, writeOutcome.NewStanding);
|
||||
Assert.True(readBack.Success);
|
||||
Assert.Equal(15, readBack.Standing);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax()
|
||||
{
|
||||
// Arrange
|
||||
await ResetFactionStandingTableAsync();
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl")
|
||||
?? throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
await using (var conn = new NpgsqlConnection(cs))
|
||||
{
|
||||
await conn.OpenAsync();
|
||||
await using var insert = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at)
|
||||
VALUES (@pid, @fid, @standing, now());
|
||||
""",
|
||||
conn);
|
||||
insert.Parameters.AddWithValue("pid", PlayerId);
|
||||
insert.Parameters.AddWithValue("fid", GridFactionId);
|
||||
insert.Parameters.AddWithValue("standing", 999);
|
||||
await insert.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
FactionStandingReadOutcome readBack;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var store = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||
readBack = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(readBack.Success);
|
||||
Assert.Equal(100, readBack.Standing);
|
||||
}
|
||||
|
||||
private async Task ResetFactionStandingTableAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
_ = Factory.Services;
|
||||
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||
|
||||
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||
var standingDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V009__player_faction_standing.sql");
|
||||
if (!File.Exists(positionDdlPath) || !File.Exists(standingDdlPath))
|
||||
{
|
||||
throw new FileNotFoundException("Test DDL for faction standing persistence not found.");
|
||||
}
|
||||
|
||||
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||
var standingDdl = await File.ReadAllTextAsync(standingDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyStanding = new NpgsqlCommand(standingDdl, conn))
|
||||
{
|
||||
await applyStanding.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
public sealed class InMemoryFactionStandingStoreTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string UnknownPlayerId = "unknown-player-xyz";
|
||||
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||
private const string RustFactionId = "prototype_faction_rust_collective";
|
||||
|
||||
[Fact]
|
||||
public void TryGetStanding_ShouldReturnZero_WhenRowMissing()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var outcome = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
Assert.Equal(0, outcome.Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyStandingDelta_ShouldReturnFifteen_WhenFirstApplyFromZero()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(0, outcome.PreviousStanding);
|
||||
Assert.Equal(15, outcome.NewStanding);
|
||||
var readBack = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
Assert.True(readBack.Success);
|
||||
Assert.Equal(15, readBack.Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyStandingDelta_ShouldClampToMax_WhenDeltaExceedsBand()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
|
||||
// Act
|
||||
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 50);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(95, outcome.PreviousStanding);
|
||||
Assert.Equal(100, outcome.NewStanding);
|
||||
Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyStandingDelta_ShouldClampToMin_WhenDeltaBelowBand()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success);
|
||||
// Act
|
||||
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -50);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(-95, outcome.PreviousStanding);
|
||||
Assert.Equal(-100, outcome.NewStanding);
|
||||
Assert.Equal(-100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
store.SeedRawStandingForTests(PlayerId, GridFactionId, 999);
|
||||
// Act
|
||||
var outcome = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
Assert.Equal(100, outcome.Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStanding_ShouldReturnUnknownFaction_WhenFactionIdEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var read = store.TryGetStanding(PlayerId, " ");
|
||||
var write = store.TryApplyStandingDelta(PlayerId, "", 5);
|
||||
// Assert
|
||||
Assert.False(read.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
|
||||
Assert.False(write.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStanding_ShouldDenyUnknownFaction_WithStructuredReason()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var read = store.TryGetStanding(PlayerId, "not_a_real_faction");
|
||||
var write = store.TryApplyStandingDelta(PlayerId, "not_a_real_faction", 5);
|
||||
// Assert
|
||||
Assert.False(read.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
|
||||
Assert.Equal(0, read.Standing);
|
||||
Assert.False(write.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
|
||||
Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyStandingDelta_ShouldApplyToClampedEffectiveStanding_WhenRawStoredValueExceedsMax()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
store.SeedRawStandingForTests(PlayerId, GridFactionId, 999);
|
||||
Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
// Act
|
||||
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -15);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(100, outcome.PreviousStanding);
|
||||
Assert.Equal(85, outcome.NewStanding);
|
||||
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var read = store.TryGetStanding(UnknownPlayerId, "not_a_real_faction");
|
||||
var write = store.TryApplyStandingDelta(UnknownPlayerId, "not_a_real_faction", 5);
|
||||
// Assert
|
||||
Assert.False(read.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
|
||||
Assert.False(write.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyStandingDelta_ShouldDenyNonWritablePlayer()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var outcome = store.TryApplyStandingDelta(UnknownPlayerId, GridFactionId, 10);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStanding_ShouldDenyNonWritablePlayer()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var outcome = store.TryGetStanding(UnknownPlayerId, GridFactionId);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyStandingDelta_ShouldTrackSeparateFactionsPerPlayer()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 15).Success);
|
||||
Assert.True(store.TryApplyStandingDelta(PlayerId, RustFactionId, 10).Success);
|
||||
// Assert
|
||||
Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Equal(10, store.TryGetStanding(PlayerId, RustFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
|
||||
var read = standingStore.TryGetStanding(PlayerId, GridFactionId);
|
||||
// Assert
|
||||
Assert.NotNull(auditStore);
|
||||
Assert.True(read.Success);
|
||||
Assert.Equal(0, read.Standing);
|
||||
}
|
||||
|
||||
private static InMemoryFactionStandingStore CreateStore()
|
||||
{
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
return new InMemoryFactionStandingStore(options, registry);
|
||||
}
|
||||
|
||||
private static FactionDefinitionRegistry CreatePrototypeRegistry()
|
||||
{
|
||||
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
|
||||
[RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId],
|
||||
};
|
||||
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||
return new FactionDefinitionRegistry(catalog);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
using NeonSprawl.Server.Game.Factions;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
public sealed class InMemoryReputationDeltaStoreTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldReturnTrueAndQueryRow_WhenFirstAppend()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
var row = CreateRow("delta-001", 15, 15);
|
||||
// Act
|
||||
var appended = store.TryAppend(row);
|
||||
var rows = store.GetDeltasForPlayer(PlayerId);
|
||||
// Assert
|
||||
Assert.True(appended);
|
||||
var read = Assert.Single(rows);
|
||||
Assert.Equal(row.Id, read.Id);
|
||||
Assert.Equal(15, read.DeltaAmount);
|
||||
Assert.Equal(15, read.ResultingStanding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldReturnFalse_WhenDuplicateId()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
var row = CreateRow("delta-dup", 10, 10);
|
||||
Assert.True(store.TryAppend(row));
|
||||
// Act
|
||||
var duplicate = store.TryAppend(row with { DeltaAmount = 99, ResultingStanding = 99 });
|
||||
// Assert
|
||||
Assert.False(duplicate);
|
||||
var read = Assert.Single(store.GetDeltasForPlayer(PlayerId));
|
||||
Assert.Equal(10, read.DeltaAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldReturnTrueTwice_WhenDistinctIds()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var first = store.TryAppend(CreateRow("delta-a", 15, 15));
|
||||
var second = store.TryAppend(CreateRow("delta-b", 10, 25));
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.True(second);
|
||||
Assert.Equal(2, store.GetDeltasForPlayer(PlayerId).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldReturnFalse_WhenRowInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
var emptyId = CreateRow("", 5, 5);
|
||||
var emptyPlayer = CreateRow("delta-x", 5, 5) with { PlayerId = " " };
|
||||
var emptyFaction = CreateRow("delta-y", 5, 5) with { FactionId = "" };
|
||||
// Act
|
||||
var noId = store.TryAppend(emptyId);
|
||||
var noPlayer = store.TryAppend(emptyPlayer);
|
||||
var noFaction = store.TryAppend(emptyFaction);
|
||||
// Assert
|
||||
Assert.False(noId);
|
||||
Assert.False(noPlayer);
|
||||
Assert.False(noFaction);
|
||||
Assert.Empty(store.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDeltasForPlayer_ShouldOrderByRecordedAtThenId()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryAppend(CreateRow("delta-z", 1, 1) with { RecordedAt = RecordedAt.AddMinutes(2) }));
|
||||
Assert.True(store.TryAppend(CreateRow("delta-a", 2, 3) with { RecordedAt = RecordedAt }));
|
||||
Assert.True(store.TryAppend(CreateRow("delta-m", 3, 6) with { RecordedAt = RecordedAt }));
|
||||
// Act
|
||||
var rows = store.GetDeltasForPlayer(PlayerId);
|
||||
// Assert
|
||||
Assert.Equal(["delta-a", "delta-m", "delta-z"], rows.Select(static r => r.Id).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDeltasForPlayer_ShouldRespectLimit_WhenProvided()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryAppend(CreateRow("delta-1", 1, 1)));
|
||||
Assert.True(store.TryAppend(CreateRow("delta-2", 1, 2)));
|
||||
// Act
|
||||
var rows = store.GetDeltasForPlayer(PlayerId, limit: 1);
|
||||
// Assert
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("delta-1", rows[0].Id);
|
||||
}
|
||||
|
||||
private static InMemoryReputationDeltaStore CreateStore() => new();
|
||||
|
||||
private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) =>
|
||||
new(
|
||||
id,
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
delta,
|
||||
resulting,
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
"prototype_quest_operator_chain",
|
||||
RecordedAt);
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||
{
|
||||
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task AppendThenGetAcrossNewFactory_ShouldPersistAuditRow()
|
||||
{
|
||||
// Arrange
|
||||
await ResetReputationDeltaTableAsync();
|
||||
var row = new ReputationDeltaRow(
|
||||
"delta-persist-001",
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
15,
|
||||
15,
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
"prototype_quest_operator_chain",
|
||||
RecordedAt);
|
||||
|
||||
// Act — write through first host
|
||||
using (var firstScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var store = firstScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
|
||||
Assert.True(store.TryAppend(row));
|
||||
}
|
||||
|
||||
IReadOnlyList<ReputationDeltaRow> readBack;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
|
||||
readBack = store.GetDeltasForPlayer(PlayerId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var persisted = Assert.Single(readBack);
|
||||
Assert.Equal(row.Id, persisted.Id);
|
||||
Assert.Equal(15, persisted.DeltaAmount);
|
||||
Assert.Equal(15, persisted.ResultingStanding);
|
||||
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, persisted.SourceKind);
|
||||
}
|
||||
|
||||
private async Task ResetReputationDeltaTableAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
_ = Factory.Services;
|
||||
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||
|
||||
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||
var auditDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V010__reputation_delta_audit.sql");
|
||||
if (!File.Exists(positionDdlPath) || !File.Exists(auditDdlPath))
|
||||
{
|
||||
throw new FileNotFoundException("Test DDL for reputation delta persistence not found.");
|
||||
}
|
||||
|
||||
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||
var auditDdl = await File.ReadAllTextAsync(auditDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyAudit = new NpgsqlCommand(auditDdl, conn))
|
||||
{
|
||||
await applyAudit.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
public sealed class ReputationOperationsTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string UnknownPlayerId = "unknown-player-xyz";
|
||||
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||
private const string RustFactionId = "prototype_faction_rust_collective";
|
||||
private const string QuestSourceId = "prototype_quest_operator_chain";
|
||||
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
var timeProvider = new FakeTimeProvider(RecordedAt);
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
15,
|
||||
"delta-001",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
timeProvider);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
Assert.Equal(0, outcome.PreviousStanding);
|
||||
Assert.Equal(15, outcome.NewStanding);
|
||||
Assert.NotNull(outcome.AuditRow);
|
||||
Assert.Equal("delta-001", outcome.AuditRow!.Id);
|
||||
Assert.Equal(15, outcome.AuditRow.DeltaAmount);
|
||||
Assert.Equal(15, outcome.AuditRow.ResultingStanding);
|
||||
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, outcome.AuditRow.SourceKind);
|
||||
Assert.Equal(QuestSourceId, outcome.AuditRow.SourceId);
|
||||
Assert.Equal(RecordedAt, outcome.AuditRow.RecordedAt);
|
||||
var rows = auditStore.GetDeltasForPlayer(PlayerId);
|
||||
var read = Assert.Single(rows);
|
||||
Assert.Equal(outcome.AuditRow, read);
|
||||
Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
50,
|
||||
"delta-max",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(95, outcome.PreviousStanding);
|
||||
Assert.Equal(100, outcome.NewStanding);
|
||||
Assert.Equal(5, outcome.AuditRow!.DeltaAmount);
|
||||
Assert.Equal(100, outcome.AuditRow.ResultingStanding);
|
||||
Assert.Equal(100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success);
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
-50,
|
||||
"delta-min",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(-95, outcome.PreviousStanding);
|
||||
Assert.Equal(-100, outcome.NewStanding);
|
||||
Assert.Equal(-5, outcome.AuditRow!.DeltaAmount);
|
||||
Assert.Equal(-100, outcome.AuditRow.ResultingStanding);
|
||||
Assert.Equal(-100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
"not_a_real_faction",
|
||||
10,
|
||||
"delta-unknown",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode);
|
||||
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
0,
|
||||
"delta-zero",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(ReputationApplyReasonCodes.InvalidDelta, outcome.ReasonCode);
|
||||
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
10,
|
||||
" ",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(ReputationApplyReasonCodes.InvalidDeltaId, outcome.ReasonCode);
|
||||
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
// Act
|
||||
var emptyKind = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
10,
|
||||
"delta-source-kind",
|
||||
" ",
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
var emptyId = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
RustFactionId,
|
||||
10,
|
||||
"delta-source-id",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
"",
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(emptyKind.Success);
|
||||
Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyKind.ReasonCode);
|
||||
Assert.False(emptyId.Success);
|
||||
Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyId.ReasonCode);
|
||||
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, RustFactionId).Standing);
|
||||
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldRollbackStanding_WhenAuditAppendFails()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
Assert.True(auditStore.TryAppend(CreateAuditRow("delta-conflict", 1, 1)));
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
15,
|
||||
"delta-conflict",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode);
|
||||
Assert.Equal(0, outcome.PreviousStanding);
|
||||
Assert.Equal(0, outcome.NewStanding);
|
||||
Assert.Null(outcome.AuditRow);
|
||||
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
|
||||
Assert.True(auditStore.TryAppend(CreateAuditRow("delta-clamp-conflict", 1, 1)));
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
50,
|
||||
"delta-clamp-conflict",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode);
|
||||
Assert.Equal(95, outcome.PreviousStanding);
|
||||
Assert.Equal(95, outcome.NewStanding);
|
||||
Assert.Null(outcome.AuditRow);
|
||||
Assert.Equal(95, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDelta_ShouldDenyNonWritablePlayer()
|
||||
{
|
||||
// Arrange
|
||||
var standingStore = CreateStandingStore();
|
||||
var auditStore = CreateAuditStore();
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
UnknownPlayerId,
|
||||
GridFactionId,
|
||||
10,
|
||||
"delta-player",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
|
||||
Assert.Empty(auditStore.GetDeltasForPlayer(UnknownPlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldApplyReputationDeltaViaOps_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
|
||||
// Act
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
15,
|
||||
"delta-host",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
standingStore,
|
||||
auditStore,
|
||||
TimeProvider.System);
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
|
||||
}
|
||||
|
||||
private static InMemoryFactionStandingStore CreateStandingStore()
|
||||
{
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
return new InMemoryFactionStandingStore(options, registry);
|
||||
}
|
||||
|
||||
private static InMemoryReputationDeltaStore CreateAuditStore() => new();
|
||||
|
||||
private static ReputationDeltaRow CreateAuditRow(string id, int delta, int resulting) =>
|
||||
new(
|
||||
id,
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
delta,
|
||||
resulting,
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
QuestSourceId,
|
||||
RecordedAt);
|
||||
|
||||
private static FactionDefinitionRegistry CreatePrototypeRegistry()
|
||||
{
|
||||
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
|
||||
[RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId],
|
||||
};
|
||||
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||
return new FactionDefinitionRegistry(catalog);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
|
|
@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests
|
|||
private const string PlayerId = "dev-local-1";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
|
|
@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests
|
|||
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
|
||||
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
|
||||
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
|
||||
var standingStore = services.GetRequiredService<IFactionStandingStore>();
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
|
|
@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests
|
|||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
standingStore,
|
||||
timeProvider).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
|
|
@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests
|
|||
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||
Assert.True(progressStore.TryActivate(PlayerId, ChainQuestId, out _));
|
||||
Assert.True(progressStore.TryMarkComplete(PlayerId, ChainQuestId, DateTimeOffset.UtcNow, out _));
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GridContractQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,4 +35,15 @@ internal static class QuestCatalogTestPaths
|
|||
|
||||
internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
|
||||
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory());
|
||||
|
||||
internal static string DiscoverRepoFactionGateRuleSchemaPath() =>
|
||||
Path.GetFullPath(
|
||||
Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "faction-gate-rule.schema.json"));
|
||||
|
||||
internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
|
||||
Path.GetFullPath(
|
||||
Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json"));
|
||||
|
||||
internal static string DiscoverRepoFactionsDirectory() =>
|
||||
Path.GetFullPath(Path.Combine(DiscoverRepoQuestsDirectory(), "..", "factions"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
|
||||
|
||||
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
|
||||
|
||||
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
|
||||
|
|
@ -115,7 +118,10 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
],
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
|
||||
"reputationGrants": [
|
||||
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
|
|
@ -167,6 +173,36 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prototype_quest_grid_contract",
|
||||
"displayName": "Grid Contract",
|
||||
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
|
||||
"factionGateRules": [
|
||||
{
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 15
|
||||
}
|
||||
],
|
||||
"completionRewardBundle": {
|
||||
"reputationGrants": [
|
||||
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "grid_contract_step_kit",
|
||||
"displayName": "Deliver survey drone kit",
|
||||
"objectives": [
|
||||
{
|
||||
"id": "grid_contract_obj_kit",
|
||||
"kind": "inventory_has_item",
|
||||
"itemId": "survey_drone_kit",
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -188,6 +224,8 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
|
||||
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
string defSchemaPath,
|
||||
string stepSchemaPath,
|
||||
string objectiveSchemaPath,
|
||||
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) =>
|
||||
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null,
|
||||
IReadOnlySet<string>? knownFactionIds = null) =>
|
||||
QuestDefinitionCatalogLoader.Load(
|
||||
questsDir,
|
||||
defSchemaPath,
|
||||
|
|
@ -224,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
KnownItemIds,
|
||||
KnownRecipeIds,
|
||||
KnownEncounterIds,
|
||||
knownFactionIds ?? KnownFactionIds,
|
||||
skillDefsById ?? RepoSkillDefs,
|
||||
NullLogger.Instance);
|
||||
|
||||
|
|
@ -238,7 +278,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
// Act
|
||||
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
// Assert
|
||||
Assert.Equal(4, catalog.DistinctQuestCount);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -250,7 +290,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
// Act
|
||||
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
// Assert
|
||||
Assert.Equal(4, catalog.DistinctQuestCount);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
|
||||
Assert.NotNull(gather);
|
||||
|
|
@ -267,6 +307,14 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
|
||||
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
|
||||
Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount);
|
||||
Assert.True(catalog.TryGetQuest(PrototypeE7M1QuestCatalogRules.GridContractQuestId, out var gridContract));
|
||||
Assert.NotNull(gridContract);
|
||||
Assert.Equal(
|
||||
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
|
||||
gridContract!.PrerequisiteQuestIds);
|
||||
Assert.NotNull(gridContract.CompletionRewardBundle);
|
||||
Assert.Empty(gridContract.CompletionRewardBundle!.ItemGrants);
|
||||
Assert.Empty(gridContract.CompletionRewardBundle.SkillXpGrants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -436,7 +484,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("prototype quest roster expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -709,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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(ex);
|
||||
Assert.Contains("factionGateRules must be", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMissingSchemaFile()
|
||||
{
|
||||
|
|
@ -734,7 +864,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
|
||||
Assert.Equal(4, questCatalog.DistinctQuestCount);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, questCatalog.DistinctQuestCount);
|
||||
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
|
||||
Assert.NotNull(gather);
|
||||
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ public class QuestDefinitionRegistryTests
|
|||
Assert.Equal(GatherIntroId, questId);
|
||||
Assert.False(unknownNormalized);
|
||||
Assert.Equal("prototype_unknown", unknownQuestId);
|
||||
Assert.Equal(4, list.Count);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, list.Count);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id));
|
||||
foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
|
|||
public class QuestDefinitionsWorldApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder()
|
||||
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFrozenQuestsInIdOrder()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
|
|
@ -21,7 +21,7 @@ public class QuestDefinitionsWorldApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.NotNull(body.Quests);
|
||||
Assert.Equal(4, body.Quests.Count);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, body.Quests.Count);
|
||||
Assert.Equal(
|
||||
PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(),
|
||||
body.Quests.Select(q => q.Id).ToArray());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public sealed class QuestFixtureApiTests
|
||||
{
|
||||
private const string DevPlayer = "dev-local-1";
|
||||
private const string FixturePath = $"/game/players/{DevPlayer}/__dev/quest-fixture";
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
|
||||
private static QuestFixtureRequest ValidFixture(params string[] completedQuestIds) =>
|
||||
new()
|
||||
{
|
||||
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
|
||||
CompletedQuestIds = completedQuestIds,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseEnvironment("Production");
|
||||
b.UseSetting("Game:EnableQuestFixtureApi", "false");
|
||||
}).CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestFixture_ShouldReturnBadRequest_WhenBodyNull()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
FixturePath,
|
||||
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var request = new QuestFixtureRequest
|
||||
{
|
||||
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion + 99,
|
||||
CompletedQuestIds = [ChainQuestId],
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(FixturePath, request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestFixture_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/missing-player/__dev/quest-fixture",
|
||||
ValidFixture(ChainQuestId));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestFixture_ShouldMarkOperatorChainCompleted_WhenNotStarted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestFixtureResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Applied);
|
||||
Assert.True(progressStore.TryGetProgress(DevPlayer, ChainQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
Assert.NotNull(progress.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestFixture_ShouldBeIdempotent_WhenQuestAlreadyCompleted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var first = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
|
||||
// Act
|
||||
var second = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
|
@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
|
||||
|
|
@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests
|
|||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests
|
|||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
|
@ -400,6 +401,7 @@ public sealed class QuestProgressApiTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
|
||||
|
|
@ -493,6 +495,7 @@ public sealed class QuestProgressApiTests
|
|||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -515,5 +518,6 @@ public sealed class QuestProgressApiTests
|
|||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests
|
|||
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
|
||||
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
CompleteChainPrerequisite(deps);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GridContractQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode);
|
||||
Assert.Null(result.Snapshot);
|
||||
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, GridContractQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
CompleteChainPrerequisite(deps);
|
||||
SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GridContractQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenStandingOneBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
CompleteChainPrerequisite(deps);
|
||||
SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding - 1);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GridContractQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
|
||||
|
|
@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests
|
|||
Assert.True(canWrite);
|
||||
}
|
||||
|
||||
private static void CompleteChainPrerequisite(QuestTestDependencies deps)
|
||||
{
|
||||
Assert.True(deps.ProgressStore.TryActivate(PlayerId, ChainQuestId, out _));
|
||||
Assert.True(deps.ProgressStore.TryMarkComplete(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
DateTimeOffset.UtcNow,
|
||||
out _));
|
||||
}
|
||||
|
||||
private static void SeedGridStanding(QuestTestDependencies deps, int standing)
|
||||
{
|
||||
if (standing == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var auditStore = deps.ReputationDeltaStore;
|
||||
var outcome = ReputationOperations.TryApplyDelta(
|
||||
PlayerId,
|
||||
GridFactionId,
|
||||
standing,
|
||||
$"seed-{standing}",
|
||||
ReputationDeltaSourceKinds.QuestCompletion,
|
||||
ChainQuestId,
|
||||
deps.StandingStore,
|
||||
auditStore,
|
||||
deps.TimeProvider);
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(standing, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
{
|
||||
var total = 0;
|
||||
|
|
@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
|
||||
|
|
@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests
|
|||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
services.GetRequiredService<IReputationDeltaStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests
|
|||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
IReputationDeltaStore ReputationDeltaStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : WebApplicationFactory<Progra
|
|||
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
|
|
@ -71,8 +75,10 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
|
||||
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
||||
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
@ -84,6 +90,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
||||
d.ServiceType == typeof(IFactionStandingStore) ||
|
||||
d.ServiceType == typeof(IReputationDeltaStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
|
|
@ -106,6 +114,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
|
||||
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Resolves faction catalog paths for local dev, tests, and container layouts (NEO-134).</summary>
|
||||
public static class FactionCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/factions</c> directory.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the factions catalog directory.
|
||||
/// Empty <paramref name="configuredFactionsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single faction row (Draft 2020-12).</summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>DI registration for the fail-fast faction catalog (NEO-134).</summary>
|
||||
public static class FactionCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="FactionDefinitionCatalog"/> and <see cref="IFactionDefinitionRegistry"/> as singletons.</summary>
|
||||
public static IServiceCollection AddFactionDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<FactionDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.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<IFactionDefinitionRegistry>(sp =>
|
||||
new FactionDefinitionRegistry(sp.GetRequiredService<FactionDefinitionCatalog>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Load-time faction row (NEO-134).</summary>
|
||||
public sealed record FactionDefRow(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
int MinStanding,
|
||||
int MaxStanding,
|
||||
int NeutralStanding);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>In-memory faction catalog loaded at startup (NEO-134). Game code should prefer <see cref="IFactionDefinitionRegistry"/> for lookups.</summary>
|
||||
public sealed class FactionDefinitionCatalog
|
||||
{
|
||||
public FactionDefinitionCatalog(
|
||||
string factionsDirectory,
|
||||
IReadOnlyDictionary<string, FactionDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
FactionsDirectory = factionsDirectory;
|
||||
ById = new ReadOnlyDictionary<string, FactionDefRow>(new Dictionary<string, FactionDefRow>(byId, StringComparer.Ordinal));
|
||||
CatalogJsonFileCount = catalogJsonFileCount;
|
||||
}
|
||||
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_factions.json</c> catalogs.</summary>
|
||||
public string FactionsDirectory { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, FactionDefRow> ById { get; }
|
||||
|
||||
public int DistinctFactionCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_factions.json</c> files under <see cref="FactionsDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>Loads and validates <c>content/factions/*_factions.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-134).</summary>
|
||||
public static class FactionDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static FactionDefinitionCatalog Load(string factionsDirectory, string schemaPath, ILogger logger)
|
||||
{
|
||||
factionsDirectory = Path.GetFullPath(factionsDirectory);
|
||||
schemaPath = Path.GetFullPath(schemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
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<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, FactionDefRow>(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<int>(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<string>();
|
||||
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<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var minStanding = (rowObj["minStanding"] as JsonValue)!.GetValue<int>();
|
||||
var maxStanding = (rowObj["maxStanding"] as JsonValue)!.GetValue<int>();
|
||||
var neutralStanding = (rowObj["neutralStanding"] as JsonValue)!.GetValue<int>();
|
||||
return new FactionDefRow(id, displayName, minStanding, maxStanding, neutralStanding);
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> 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<string> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Adapter over <see cref="FactionDefinitionCatalog"/> (NEO-134).</summary>
|
||||
public sealed class FactionDefinitionRegistry(FactionDefinitionCatalog catalog) : IFactionDefinitionRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder()
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var list = new List<FactionDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
list.Add(catalog.ById[id]);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Result of <see cref="FactionGateOperations.TryEvaluate"/> (NEO-137).</summary>
|
||||
public readonly record struct FactionGateEvaluateOutcome(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
string? FailingFactionId,
|
||||
int? RequiredMinStanding,
|
||||
int? CurrentStanding);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Stable deny reason codes for <see cref="FactionGateOperations.TryEvaluate"/> (NEO-137).</summary>
|
||||
public static class FactionGateEvaluateReasonCodes
|
||||
{
|
||||
public const string GateBlocked = "gate_blocked";
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using NeonSprawl.Server.Game.Quests;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates <see cref="FactionGateRuleRow"/> minimum-standing gates (NEO-137).
|
||||
/// Quest accept wiring: <see cref="QuestStateOperations.TryAccept"/>.
|
||||
/// NEO-141 telemetry hook site: deny return path below.
|
||||
/// </summary>
|
||||
public static class FactionGateOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// All rules must pass (AND). Empty rules succeed immediately.
|
||||
/// Compares player standing vs <see cref="FactionGateRuleRow.MinStanding"/> with <c>standing >= minStanding</c>.
|
||||
/// </summary>
|
||||
public static FactionGateEvaluateOutcome TryEvaluate(
|
||||
string playerId,
|
||||
IReadOnlyList<FactionGateRuleRow> rules,
|
||||
IFactionStandingStore standingStore)
|
||||
{
|
||||
var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return Success();
|
||||
}
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var factionId = FactionStandingIds.NormalizeFactionId(rule.FactionId);
|
||||
var read = standingStore.TryGetStanding(normalizedPlayerId, factionId);
|
||||
if (!read.Success)
|
||||
{
|
||||
return Deny(read.ReasonCode, factionId, rule.MinStanding, null);
|
||||
}
|
||||
|
||||
if (read.Standing < rule.MinStanding)
|
||||
{
|
||||
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `faction_gate_blocked` ---
|
||||
// TODO(E9.M1): catalog emit — playerId, factionId, minStanding, currentStanding, gate context.
|
||||
// No ingest or ILogger here (comments-only).
|
||||
return Deny(
|
||||
FactionGateEvaluateReasonCodes.GateBlocked,
|
||||
factionId,
|
||||
rule.MinStanding,
|
||||
read.Standing);
|
||||
}
|
||||
}
|
||||
|
||||
return Success();
|
||||
}
|
||||
|
||||
private static FactionGateEvaluateOutcome Success() =>
|
||||
new(true, null, null, null, null);
|
||||
|
||||
private static FactionGateEvaluateOutcome Deny(
|
||||
string? reasonCode,
|
||||
string factionId,
|
||||
int minStanding,
|
||||
int? currentStanding) =>
|
||||
new(false, reasonCode, factionId, minStanding, currentStanding);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Id normalization for faction standing stores (NEO-135).</summary>
|
||||
public static class FactionStandingIds
|
||||
{
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var trimmed = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Trim; empty when input is null/whitespace. Faction ids stay case-sensitive (catalog ids are lowercase snake).</summary>
|
||||
public static string NormalizeFactionId(string? factionId)
|
||||
{
|
||||
var trimmed = factionId?.Trim();
|
||||
return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed;
|
||||
}
|
||||
|
||||
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="factionId"/>.</summary>
|
||||
public static string MakeStandingKey(string? playerId, string? factionId)
|
||||
{
|
||||
var player = NormalizePlayerId(playerId);
|
||||
var faction = NormalizeFactionId(factionId);
|
||||
if (player.Length == 0 || faction.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{player}\0{faction}";
|
||||
}
|
||||
|
||||
/// <summary>Clamps <paramref name="standing"/> to the faction definition band.</summary>
|
||||
public static int ClampStanding(int standing, FactionDefRow definition) =>
|
||||
Math.Clamp(standing, definition.MinStanding, definition.MaxStanding);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Result of <see cref="IFactionStandingStore.TryApplyStandingDelta"/> (NEO-135).</summary>
|
||||
public readonly record struct FactionStandingMutationOutcome(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
int PreviousStanding,
|
||||
int NewStanding);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Result of <see cref="IFactionStandingStore.TryGetStanding"/> (NEO-135).</summary>
|
||||
public readonly record struct FactionStandingReadOutcome(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
int Standing);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Stable deny reason codes for faction standing store operations (NEO-135).</summary>
|
||||
public static class FactionStandingReasonCodes
|
||||
{
|
||||
public const string UnknownFaction = "unknown_faction";
|
||||
|
||||
public const string PlayerNotWritable = "player_not_writable";
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Registers faction standing + reputation delta audit persistence (NEO-135).</summary>
|
||||
public static class FactionStandingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set; otherwise in-memory fallback.</summary>
|
||||
public static IServiceCollection AddFactionStandingStores(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
services.AddSingleton<IFactionStandingStore, PostgresFactionStandingStore>();
|
||||
services.AddSingleton<IReputationDeltaStore, PostgresReputationDeltaStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
|
||||
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to validated <see cref="FactionDefRow"/> entries loaded at startup (<see cref="FactionDefinitionCatalog"/>).
|
||||
/// </summary>
|
||||
public interface IFactionDefinitionRegistry
|
||||
{
|
||||
/// <summary>Attempts to resolve a faction by stable <c>id</c> (see <c>faction-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||
bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition);
|
||||
|
||||
/// <summary>Every loaded definition, ordered by <see cref="FactionDefRow.Id"/> (ordinal).</summary>
|
||||
IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder();
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted per-player faction standing keyed by <c>(playerId, factionId)</c> (NEO-135).
|
||||
/// Missing row ⇒ neutral <c>0</c> at read. Consumed by <c>ReputationOperations</c> (NEO-136) and gate eval (NEO-137).
|
||||
/// </summary>
|
||||
public interface IFactionStandingStore
|
||||
{
|
||||
/// <summary>Whether mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
|
||||
bool CanWritePlayer(string playerId);
|
||||
|
||||
/// <summary>
|
||||
/// Reads standing for one faction. Missing row ⇒ <c>0</c> clamped to faction min/max.
|
||||
/// Unknown faction id ⇒ structured deny with <see cref="FactionStandingReasonCodes.UnknownFaction"/>.
|
||||
/// </summary>
|
||||
FactionStandingReadOutcome TryGetStanding(string playerId, string factionId);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a signed delta, clamps resulting standing to faction min/max, and persists.
|
||||
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
|
||||
/// </summary>
|
||||
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Append-only reputation delta audit log (NEO-135).</summary>
|
||||
public interface IReputationDeltaStore
|
||||
{
|
||||
/// <summary>
|
||||
/// First append with a given row <c>Id</c> returns <c>true</c>; duplicate ids return <c>false</c>.
|
||||
/// Postgres: row references <c>player_position</c> — orchestrators (NEO-136) should apply standing before audit append.
|
||||
/// </summary>
|
||||
bool TryAppend(ReputationDeltaRow row);
|
||||
|
||||
/// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary>
|
||||
IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135).</summary>
|
||||
public sealed class InMemoryFactionStandingStore(
|
||||
IOptions<GamePositionOptions> options,
|
||||
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
|
||||
{
|
||||
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
|
||||
|
||||
private readonly ConcurrentDictionary<string, int> standingByKey = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
|
||||
|
||||
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
|
||||
{
|
||||
var id = FactionStandingIds.NormalizePlayerId(o.DevPlayerId);
|
||||
if (id.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanWritePlayer(string playerId)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
return player.Length > 0 && knownPlayers.Contains(player);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
if (faction.Length == 0)
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
if (!CanWritePlayer(player))
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
var key = FactionStandingIds.MakeStandingKey(player, faction);
|
||||
var raw = standingByKey.GetValueOrDefault(key, 0);
|
||||
return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
if (faction.Length == 0)
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
if (!CanWritePlayer(player))
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
var key = FactionStandingIds.MakeStandingKey(player, faction);
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
var raw = standingByKey.GetValueOrDefault(key, 0);
|
||||
var previousStanding = FactionStandingIds.ClampStanding(raw, definition);
|
||||
var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition);
|
||||
standingByKey[key] = newStanding;
|
||||
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
|
||||
}
|
||||
}
|
||||
|
||||
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
|
||||
new(false, reasonCode, 0);
|
||||
|
||||
private static FactionStandingMutationOutcome DenyMutation(string reasonCode) =>
|
||||
new(false, reasonCode, 0, 0);
|
||||
|
||||
/// <summary>Test-only seed of raw standing before read-clamp (NEO-135 unit tests).</summary>
|
||||
internal void SeedRawStandingForTests(string playerId, string factionId, int rawStanding)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||
if (player.Length == 0 || faction.Length == 0 || !CanWritePlayer(player))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = FactionStandingIds.MakeStandingKey(player, faction);
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
standingByKey[key] = rawStanding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Thread-safe in-memory append-only reputation delta audit log (NEO-135).</summary>
|
||||
public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ReputationDeltaRow> rowsById = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryAppend(ReputationDeltaRow row)
|
||||
{
|
||||
if (!IsValidRow(row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = row with
|
||||
{
|
||||
Id = row.Id.Trim(),
|
||||
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
|
||||
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
|
||||
SourceKind = row.SourceKind.Trim(),
|
||||
SourceId = row.SourceId.Trim(),
|
||||
};
|
||||
|
||||
lock (idLocks.GetOrAdd(normalized.Id, _ => new object()))
|
||||
{
|
||||
if (rowsById.ContainsKey(normalized.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
rowsById[normalized.Id] = normalized;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
IEnumerable<ReputationDeltaRow> query = rowsById.Values
|
||||
.Where(r => string.Equals(r.PlayerId, player, StringComparison.Ordinal))
|
||||
.OrderBy(static r => r.RecordedAt)
|
||||
.ThenBy(static r => r.Id, StringComparer.Ordinal);
|
||||
|
||||
if (limit is > 0)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return query.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsValidRow(ReputationDeltaRow row) =>
|
||||
row.Id.Trim().Length > 0 &&
|
||||
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
|
||||
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
|
||||
row.SourceKind.Trim().Length > 0 &&
|
||||
row.SourceId.Trim().Length > 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135).</summary>
|
||||
public sealed class PostgresFactionStandingStore(
|
||||
Npgsql.NpgsqlDataSource dataSource,
|
||||
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool CanWritePlayer(string playerId)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
return PlayerExists(conn, player);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
if (faction.Length == 0)
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
if (!PlayerExists(conn, player))
|
||||
{
|
||||
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT standing
|
||||
FROM player_faction_standing
|
||||
WHERE player_id = @pid AND faction_id = @fid
|
||||
LIMIT 1;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", player);
|
||||
cmd.Parameters.AddWithValue("fid", faction);
|
||||
var scalar = cmd.ExecuteScalar();
|
||||
var raw = scalar is null or DBNull
|
||||
? 0
|
||||
: scalar is int xi
|
||||
? xi
|
||||
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
|
||||
return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
if (faction.Length == 0)
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
|
||||
{
|
||||
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
|
||||
}
|
||||
|
||||
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, player, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
|
||||
}
|
||||
|
||||
var raw = 0;
|
||||
using (var sel = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT standing
|
||||
FROM player_faction_standing
|
||||
WHERE player_id = @pid AND faction_id = @fid
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
sel.Parameters.AddWithValue("pid", player);
|
||||
sel.Parameters.AddWithValue("fid", faction);
|
||||
var scalar = sel.ExecuteScalar();
|
||||
raw = scalar is null or DBNull
|
||||
? 0
|
||||
: scalar is int xi
|
||||
? xi
|
||||
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var previousStanding = FactionStandingIds.ClampStanding(raw, definition);
|
||||
var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition);
|
||||
|
||||
using var upsert = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at)
|
||||
VALUES (@pid, @fid, @standing, now())
|
||||
ON CONFLICT (player_id, faction_id)
|
||||
DO UPDATE SET standing = EXCLUDED.standing, updated_at = now();
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
upsert.Parameters.AddWithValue("pid", player);
|
||||
upsert.Parameters.AddWithValue("fid", faction);
|
||||
upsert.Parameters.AddWithValue("standing", newStanding);
|
||||
upsert.ExecuteNonQuery();
|
||||
tx.Commit();
|
||||
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
|
||||
}
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
|
||||
new(false, reasonCode, 0);
|
||||
|
||||
private static FactionStandingMutationOutcome DenyMutation(string reasonCode) =>
|
||||
new(false, reasonCode, 0, 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Applies NEO-135 faction standing table DDL once per process.</summary>
|
||||
public static class PostgresPlayerFactionStandingBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V009__player_faction_standing.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NEO-135 faction standing DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Applies NEO-135 reputation delta audit table DDL once per process.</summary>
|
||||
public static class PostgresReputationDeltaBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V010__reputation_delta_audit.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NEO-135 reputation delta DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
|
||||
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool TryAppend(ReputationDeltaRow row)
|
||||
{
|
||||
if (!IsValidRow(row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = row with
|
||||
{
|
||||
Id = row.Id.Trim(),
|
||||
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
|
||||
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
|
||||
SourceKind = row.SourceKind.Trim(),
|
||||
SourceId = row.SourceId.Trim(),
|
||||
};
|
||||
|
||||
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO reputation_delta_audit (
|
||||
id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)
|
||||
VALUES (@id, @pid, @fid, @delta, @result, @kind, @source_id, @recorded)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("id", normalized.Id);
|
||||
cmd.Parameters.AddWithValue("pid", normalized.PlayerId);
|
||||
cmd.Parameters.AddWithValue("fid", normalized.FactionId);
|
||||
cmd.Parameters.AddWithValue("delta", normalized.DeltaAmount);
|
||||
cmd.Parameters.AddWithValue("result", normalized.ResultingStanding);
|
||||
cmd.Parameters.AddWithValue("kind", normalized.SourceKind);
|
||||
cmd.Parameters.AddWithValue("source_id", normalized.SourceId);
|
||||
cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt);
|
||||
return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
|
||||
{
|
||||
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
if (player.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
var sql = limit is > 0
|
||||
? """
|
||||
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
|
||||
FROM reputation_delta_audit
|
||||
WHERE player_id = @pid
|
||||
ORDER BY recorded_at ASC, id ASC
|
||||
LIMIT @lim;
|
||||
"""
|
||||
: """
|
||||
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
|
||||
FROM reputation_delta_audit
|
||||
WHERE player_id = @pid
|
||||
ORDER BY recorded_at ASC, id ASC;
|
||||
""";
|
||||
using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("pid", player);
|
||||
if (limit is > 0)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("lim", limit.Value);
|
||||
}
|
||||
|
||||
var rows = new List<ReputationDeltaRow>();
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(new ReputationDeltaRow(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetInt32(3),
|
||||
reader.GetInt32(4),
|
||||
reader.GetString(5),
|
||||
reader.GetString(6),
|
||||
reader.GetFieldValue<DateTimeOffset>(7)));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static bool IsValidRow(ReputationDeltaRow row) =>
|
||||
row.Id.Trim().Length > 0 &&
|
||||
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
|
||||
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
|
||||
row.SourceKind.Trim().Length > 0 &&
|
||||
row.SourceId.Trim().Length > 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E7M3 faction catalog gates (NEO-134), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_*</c> and <c>_prototype_e7m3_faction_*</c> gate functions.
|
||||
/// </summary>
|
||||
public static class PrototypeE7M3FactionCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedFactionIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"prototype_faction_grid_operators",
|
||||
"prototype_faction_rust_collective",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_FREEZE</c>.</summary>
|
||||
public static readonly FrozenDictionary<string, FactionDefRow> ExpectedFactionFreeze =
|
||||
new Dictionary<string, FactionDefRow>(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);
|
||||
|
||||
/// <summary>Returns a human-readable error if the prototype faction roster fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetRosterGateError(IReadOnlyDictionary<string, string> 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;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if faction rows diverge from the E7M3 freeze table, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetFreezeGateError(IReadOnlyDictionary<string, FactionDefRow> 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;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if standing band ordering fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetStandingBandGateError(IReadOnlyDictionary<string, FactionDefRow> 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Result of <see cref="ReputationOperations.TryApplyDelta"/> (NEO-136).</summary>
|
||||
public readonly record struct ReputationApplyOutcome(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
int PreviousStanding,
|
||||
int NewStanding,
|
||||
ReputationDeltaRow? AuditRow);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Stable deny reason codes for <see cref="ReputationOperations.TryApplyDelta"/> (NEO-136).</summary>
|
||||
public static class ReputationApplyReasonCodes
|
||||
{
|
||||
public const string InvalidDelta = "invalid_delta";
|
||||
|
||||
public const string InvalidDeltaId = "invalid_delta_id";
|
||||
|
||||
public const string InvalidSource = "invalid_source";
|
||||
|
||||
public const string AuditAppendFailed = "audit_append_failed";
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Append-only reputation delta audit row (NEO-135).</summary>
|
||||
public sealed record ReputationDeltaRow(
|
||||
string Id,
|
||||
string PlayerId,
|
||||
string FactionId,
|
||||
int DeltaAmount,
|
||||
int ResultingStanding,
|
||||
string SourceKind,
|
||||
string SourceId,
|
||||
DateTimeOffset RecordedAt);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Stable source-kind values for <see cref="ReputationDeltaRow"/> (NEO-135).</summary>
|
||||
public static class ReputationDeltaSourceKinds
|
||||
{
|
||||
public const string QuestCompletion = "quest_completion";
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates auditable faction standing apply: standing mutation + append-only audit row (NEO-136).
|
||||
/// Quest reward wiring: <see cref="Rewards.RewardRouterOperations"/> (NEO-138).
|
||||
/// NEO-141 telemetry hook site: successful apply path in <see cref="TryApplyDelta"/>.
|
||||
/// </summary>
|
||||
public static class ReputationOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a signed standing delta for one player+faction, clamps to faction bounds, and appends one audit row.
|
||||
/// Callers must not invoke <see cref="IFactionStandingStore.TryApplyStandingDelta"/> alone for game apply.
|
||||
/// </summary>
|
||||
public static ReputationApplyOutcome TryApplyDelta(
|
||||
string playerId,
|
||||
string factionId,
|
||||
int deltaAmount,
|
||||
string deltaId,
|
||||
string sourceKind,
|
||||
string sourceId,
|
||||
IFactionStandingStore standingStore,
|
||||
IReputationDeltaStore auditStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (deltaAmount == 0)
|
||||
{
|
||||
return Deny(ReputationApplyReasonCodes.InvalidDelta);
|
||||
}
|
||||
|
||||
var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId);
|
||||
var normalizedFactionId = FactionStandingIds.NormalizeFactionId(factionId);
|
||||
var normalizedDeltaId = deltaId.Trim();
|
||||
if (normalizedDeltaId.Length == 0)
|
||||
{
|
||||
return Deny(ReputationApplyReasonCodes.InvalidDeltaId);
|
||||
}
|
||||
|
||||
var normalizedSourceKind = sourceKind.Trim();
|
||||
var normalizedSourceId = sourceId.Trim();
|
||||
if (normalizedSourceKind.Length == 0 || normalizedSourceId.Length == 0)
|
||||
{
|
||||
return Deny(ReputationApplyReasonCodes.InvalidSource);
|
||||
}
|
||||
|
||||
var mutation = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, deltaAmount);
|
||||
if (!mutation.Success)
|
||||
{
|
||||
return new ReputationApplyOutcome(
|
||||
false,
|
||||
mutation.ReasonCode,
|
||||
mutation.PreviousStanding,
|
||||
mutation.NewStanding,
|
||||
null);
|
||||
}
|
||||
|
||||
var appliedDelta = mutation.NewStanding - mutation.PreviousStanding;
|
||||
var row = new ReputationDeltaRow(
|
||||
normalizedDeltaId,
|
||||
normalizedPlayerId,
|
||||
normalizedFactionId,
|
||||
appliedDelta,
|
||||
mutation.NewStanding,
|
||||
normalizedSourceKind,
|
||||
normalizedSourceId,
|
||||
timeProvider.GetUtcNow());
|
||||
|
||||
if (auditStore.TryAppend(row))
|
||||
{
|
||||
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` ---
|
||||
return new ReputationApplyOutcome(
|
||||
true,
|
||||
null,
|
||||
mutation.PreviousStanding,
|
||||
mutation.NewStanding,
|
||||
row);
|
||||
}
|
||||
|
||||
if (appliedDelta != 0)
|
||||
{
|
||||
_ = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, -appliedDelta);
|
||||
}
|
||||
|
||||
return new ReputationApplyOutcome(
|
||||
false,
|
||||
ReputationApplyReasonCodes.AuditAppendFailed,
|
||||
mutation.PreviousStanding,
|
||||
mutation.PreviousStanding,
|
||||
null);
|
||||
}
|
||||
|
||||
private static ReputationApplyOutcome Deny(string reasonCode) =>
|
||||
new(false, reasonCode, 0, 0, null);
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@ public sealed class GamePositionOptions
|
|||
/// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary>
|
||||
public bool EnableCombatTargetFixtureApi { get; set; }
|
||||
|
||||
/// <summary>When true, maps <c>POST …/__dev/quest-fixture</c> for Bruno/manual QA quest progress setup (also enabled in Development).</summary>
|
||||
public bool EnableQuestFixtureApi { get; set; }
|
||||
|
||||
/// <summary>World position for the dev player at process start.</summary>
|
||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Minimum standing gate on <see cref="QuestDefRow.FactionGateRules"/> (NEO-134).</summary>
|
||||
public sealed record FactionGateRuleRow(string FactionId, int MinStanding);
|
||||
|
|
@ -3,26 +3,31 @@ using System.Collections.Frozen;
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>,
|
||||
/// Prototype quest roster + cross-ref gates (NEO-113, NEO-133), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_QUEST_IDS</c> (five ids),
|
||||
/// <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>, <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>,
|
||||
/// and <c>_prototype_e7m1_*</c> gate functions.
|
||||
/// and <c>_prototype_e7m1_*</c> / <c>_prototype_e7m3_*</c> gate functions.
|
||||
/// Class name retained for NEO-113 call sites; rename to <c>PrototypeE7M3QuestCatalogRules</c> planned in NEO-134.
|
||||
/// </summary>
|
||||
public static class PrototypeE7M1QuestCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>.</summary>
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_QUEST_IDS</c> (five quests).</summary>
|
||||
public static readonly FrozenSet<string> ExpectedQuestIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_grid_contract",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
|
||||
public const string ChainQuestId = "prototype_quest_operator_chain";
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID</c>.</summary>
|
||||
public const string GridContractQuestId = "prototype_quest_grid_contract";
|
||||
|
||||
/// <summary>First frozen onboarding quest id (gather intro).</summary>
|
||||
public const string GatherIntroQuestId = "prototype_quest_gather_intro";
|
||||
|
||||
|
|
@ -41,14 +46,14 @@ public static class PrototypeE7M1QuestCatalogRules
|
|||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
|
||||
public const string ChainTerminalItemId = "contract_handoff_token";
|
||||
|
||||
/// <summary>Returns a human-readable error if the E7M1 quest contract fails, otherwise <see langword="null"/>.</summary>
|
||||
/// <summary>Returns a human-readable error if the prototype quest roster fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE7M1GateError(IReadOnlyDictionary<string, string> questIdToSourceFile)
|
||||
{
|
||||
var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedQuestIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E7M1 expects exactly quest ids " +
|
||||
"error: prototype quest roster expects exactly quest ids " +
|
||||
$"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ public static class PrototypeE7M2QuestCatalogRules
|
|||
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]),
|
||||
[PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new(
|
||||
[],
|
||||
[]),
|
||||
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise <see langword="null"/>.</summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
using System.Collections.Frozen;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E7M3 quest faction gates (NEO-134), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_*</c> and <c>_prototype_e7m3_*</c> gate functions.
|
||||
/// </summary>
|
||||
public static class PrototypeE7M3QuestFactionRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID</c>.</summary>
|
||||
public const string GridContractGateFactionId = "prototype_faction_grid_operators";
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING</c>.</summary>
|
||||
public const int GridContractMinStanding = 15;
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID</c>.</summary>
|
||||
public const string GridContractObjectiveItemId = "survey_drone_kit";
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_COMPLETION_BUNDLES</c>.</summary>
|
||||
public static readonly FrozenDictionary<string, QuestRewardBundleRow> ExpectedCompletionBundles =
|
||||
new Dictionary<string, QuestRewardBundleRow>(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);
|
||||
|
||||
/// <summary>Returns a human-readable error if quest faction refs fail, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetFactionCrossRefError(
|
||||
IReadOnlyDictionary<string, QuestDefRow> rowsById,
|
||||
IReadOnlySet<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if bundle contents diverge from the E7M3 freeze table, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary<string, QuestDefRow> 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;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if grid contract quest shape fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetGridContractShapeError(IReadOnlyDictionary<string, QuestDefRow> 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);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -18,7 +19,8 @@ public static class QuestAcceptApi
|
|||
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
||||
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
|
||||
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
|
||||
IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is not null &&
|
||||
body.SchemaVersion != 0 &&
|
||||
|
|
@ -46,6 +48,7 @@ public static class QuestAcceptApi
|
|||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
standingStore,
|
||||
timeProvider);
|
||||
|
||||
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
|
||||
|
|
|
|||
|
|
@ -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<RecipeDefinitionCatalog>();
|
||||
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
||||
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Load-time quest row (NEO-113).</summary>
|
||||
/// <summary>Load-time quest row (NEO-113, NEO-134).</summary>
|
||||
public sealed class QuestDefRow(
|
||||
string id,
|
||||
string displayName,
|
||||
IReadOnlyList<string> prerequisiteQuestIds,
|
||||
IReadOnlyList<QuestStepDefRow> steps,
|
||||
QuestRewardBundleRow? completionRewardBundle = null)
|
||||
QuestRewardBundleRow? completionRewardBundle = null,
|
||||
IReadOnlyList<FactionGateRuleRow>? factionGateRules = null)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
|
||||
|
|
@ -17,4 +18,6 @@ public sealed class QuestDefRow(
|
|||
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
|
||||
|
||||
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle;
|
||||
|
||||
public IReadOnlyList<FactionGateRuleRow> FactionGateRules { get; } = factionGateRules ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ using NeonSprawl.Server.Game.Skills;
|
|||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125).</summary>
|
||||
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125, NEO-134).</summary>
|
||||
public static class QuestDefinitionCatalogLoader
|
||||
{
|
||||
private static JsonSchema? _questObjectiveDefSchema;
|
||||
|
|
@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader
|
|||
IReadOnlySet<string> knownItemIds,
|
||||
IReadOnlySet<string> knownRecipeIds,
|
||||
IReadOnlySet<string> knownEncounterIds,
|
||||
IReadOnlySet<string> knownFactionIds,
|
||||
IReadOnlyDictionary<string, SkillDefRow> 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<string>();
|
||||
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<FactionGateRuleRow> ParseFactionGateRules(JsonArray? array)
|
||||
{
|
||||
if (array is null || array.Count == 0)
|
||||
return [];
|
||||
|
||||
var rows = new List<FactionGateRuleRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject ruleObj)
|
||||
continue;
|
||||
|
||||
var factionId = (ruleObj["factionId"] as JsonValue)!.GetValue<string>();
|
||||
var minStanding = (ruleObj["minStanding"] as JsonValue)!.GetValue<int>();
|
||||
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<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array)
|
||||
{
|
||||
if (array is null || array.Count == 0)
|
||||
return [];
|
||||
|
||||
var rows = new List<ReputationGrantRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject grantObj)
|
||||
continue;
|
||||
|
||||
var factionId = (grantObj["factionId"] as JsonValue)!.GetValue<string>();
|
||||
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
|
||||
rows.Add(new ReputationGrantRow(factionId, amount));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
|
||||
|
|
@ -451,6 +513,9 @@ public static class QuestDefinitionCatalogLoader
|
|||
if (_questDefSchema is not null)
|
||||
return;
|
||||
|
||||
var schemaDir = Path.GetDirectoryName(questDefSchemaPath)!;
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "faction-gate-rule.schema.json"));
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "reputation-grant-row.schema.json"));
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Dev-only route to seed quest progress for Bruno and manual QA (NEO-137).</summary>
|
||||
public static class QuestFixtureApi
|
||||
{
|
||||
public static WebApplication MapQuestFixtureApi(this WebApplication app)
|
||||
{
|
||||
app.MapPost(
|
||||
"/game/players/{id}/__dev/quest-fixture",
|
||||
(string id, QuestFixtureRequest? body, IPositionStateStore positions,
|
||||
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var trimmedId = id.Trim();
|
||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!QuestFixtureOperations.TryApply(
|
||||
trimmedId,
|
||||
body,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
timeProvider))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(new QuestFixtureResponse { Applied = true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/quest-fixture</c> (NEO-137 Bruno/manual QA).</summary>
|
||||
public sealed class QuestFixtureRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Quest ids to mark completed via store activate+complete (no reward delivery).</summary>
|
||||
public IReadOnlyList<string> CompletedQuestIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>POST response for quest fixture apply.</summary>
|
||||
public sealed class QuestFixtureResponse
|
||||
{
|
||||
public bool Applied { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
|
||||
public static class QuestFixtureOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks each quest completed via store activate + mark-complete (no reward delivery).
|
||||
/// Idempotent when a quest is already completed.
|
||||
/// </summary>
|
||||
public static bool TryApply(
|
||||
string playerId,
|
||||
QuestFixtureRequest body,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (body.CompletedQuestIds.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!progressStore.CanWritePlayer(playerId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var questId in body.CompletedQuestIds)
|
||||
{
|
||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing) &&
|
||||
existing.Status == QuestProgressStatus.Completed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out _))
|
||||
{
|
||||
if (!progressStore.TryActivate(playerId, normalizedQuestId, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (progressStore.TryMarkComplete(
|
||||
playerId,
|
||||
normalizedQuestId,
|
||||
timeProvider.GetUtcNow(),
|
||||
out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
|
||||
afterAttempt.Status == QuestProgressStatus.Completed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,16 @@ using NeonSprawl.Server.Game.Encounters;
|
|||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125).</summary>
|
||||
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125, NEO-134).</summary>
|
||||
public sealed record QuestRewardBundleRow(
|
||||
IReadOnlyList<RewardGrantRow> ItemGrants,
|
||||
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants);
|
||||
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants,
|
||||
IReadOnlyList<ReputationGrantRow> ReputationGrants)
|
||||
{
|
||||
public QuestRewardBundleRow(
|
||||
IReadOnlyList<RewardGrantRow> itemGrants,
|
||||
IReadOnlyList<QuestSkillXpGrantRow> skillXpGrants)
|
||||
: this(itemGrants, skillXpGrants, [])
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Rewards;
|
||||
|
|
@ -29,6 +30,7 @@ public static class QuestStateOperations
|
|||
PerkUnlockEngine perkUnlockEngine,
|
||||
IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
IFactionStandingStore standingStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
||||
|
|
@ -61,6 +63,16 @@ public static class QuestStateOperations
|
|||
}
|
||||
}
|
||||
|
||||
var gate = FactionGateOperations.TryEvaluate(
|
||||
playerId,
|
||||
definition.FactionGateRules,
|
||||
standingStore);
|
||||
if (!gate.Success)
|
||||
{
|
||||
// Quest-level deny always surfaces faction_gate_blocked (ops may carry unknown_faction for telemetry).
|
||||
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
|
||||
}
|
||||
|
||||
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
|
||||
{
|
||||
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---
|
||||
|
|
|
|||
|
|
@ -16,4 +16,6 @@ public static class QuestStateReasonCodes
|
|||
public const string NotActive = "not_active";
|
||||
|
||||
public const string InvalidStepIndex = "invalid_step_index";
|
||||
|
||||
public const string FactionGateBlocked = "faction_gate_blocked";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Reputation grant row on <see cref="QuestRewardBundleRow.ReputationGrants"/> (NEO-134).</summary>
|
||||
public sealed record ReputationGrantRow(string FactionId, int Amount);
|
||||
|
|
@ -154,4 +154,16 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? QuestObjectiveDefSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/factions</c> directory.
|
||||
/// </summary>
|
||||
public string? FactionsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>faction-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? FactionDefSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue