Merge pull request #176 from ViPro-Technologies/NEO-137-e7m3-faction-gate-operations-tryaccept
NEO-137: FactionGateOperations + wire into TryAcceptpull/177/head
commit
0af21e002c
|
|
@ -93,6 +93,7 @@ jobs:
|
||||||
ConnectionStrings__NeonSprawl: >-
|
ConnectionStrings__NeonSprawl: >-
|
||||||
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
||||||
Game__EnableCombatTargetFixtureApi: "true"
|
Game__EnableCombatTargetFixtureApi: "true"
|
||||||
|
Game__EnableQuestFixtureApi: "true"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
|
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
| **Module ID** | E7.M3 |
|
| **Module ID** | E7.M3 |
|
||||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||||
| **Stage target** | Pre-production |
|
| **Stage target** | Pre-production |
|
||||||
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-05** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
|
| **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) |
|
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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,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**.
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Quests;
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
|
@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests
|
||||||
private const string PlayerId = "dev-local-1";
|
private const string PlayerId = "dev-local-1";
|
||||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||||
|
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||||
|
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
|
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||||
|
|
@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests
|
||||||
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
|
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
|
||||||
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
|
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
|
||||||
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
|
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
|
||||||
|
var standingStore = services.GetRequiredService<IFactionStandingStore>();
|
||||||
var timeProvider = TimeProvider.System;
|
var timeProvider = TimeProvider.System;
|
||||||
Assert.True(QuestStateOperations.TryAccept(
|
Assert.True(QuestStateOperations.TryAccept(
|
||||||
PlayerId,
|
PlayerId,
|
||||||
|
|
@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests
|
||||||
perkUnlockEngine,
|
perkUnlockEngine,
|
||||||
perkStore,
|
perkStore,
|
||||||
deliveryStore,
|
deliveryStore,
|
||||||
|
standingStore,
|
||||||
timeProvider).Success);
|
timeProvider).Success);
|
||||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||||
PlayerId,
|
PlayerId,
|
||||||
|
|
@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests
|
||||||
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
|
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
|
||||||
Assert.Null(body.Quest);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 Microsoft.Extensions.DependencyInjection;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Gathering;
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests
|
||||||
deps.PerkUnlockEngine,
|
deps.PerkUnlockEngine,
|
||||||
deps.PerkStore,
|
deps.PerkStore,
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
deps.TimeProvider);
|
deps.TimeProvider);
|
||||||
|
|
||||||
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
|
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
|
||||||
|
|
@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests
|
||||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests
|
||||||
IEncounterCompleteEventStore CompleteEventStore,
|
IEncounterCompleteEventStore CompleteEventStore,
|
||||||
IPlayerPerkStateStore PerkStore,
|
IPlayerPerkStateStore PerkStore,
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
TimeProvider TimeProvider);
|
TimeProvider TimeProvider);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Gathering;
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -400,6 +401,7 @@ public sealed class QuestProgressApiTests
|
||||||
deps.PerkUnlockEngine,
|
deps.PerkUnlockEngine,
|
||||||
deps.PerkStore,
|
deps.PerkStore,
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
deps.TimeProvider);
|
deps.TimeProvider);
|
||||||
|
|
||||||
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
|
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
|
||||||
|
|
@ -493,6 +495,7 @@ public sealed class QuestProgressApiTests
|
||||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,5 +518,6 @@ public sealed class QuestProgressApiTests
|
||||||
IEncounterCompleteEventStore CompleteEventStore,
|
IEncounterCompleteEventStore CompleteEventStore,
|
||||||
IPlayerPerkStateStore PerkStore,
|
IPlayerPerkStateStore PerkStore,
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
TimeProvider TimeProvider);
|
TimeProvider TimeProvider);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests
|
||||||
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
|
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
|
||||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||||
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
|
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]
|
[Fact]
|
||||||
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
|
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
|
||||||
|
|
@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests
|
||||||
Assert.True(canWrite);
|
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)
|
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||||
{
|
{
|
||||||
var total = 0;
|
var total = 0;
|
||||||
|
|
@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests
|
||||||
deps.PerkUnlockEngine,
|
deps.PerkUnlockEngine,
|
||||||
deps.PerkStore,
|
deps.PerkStore,
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
deps.TimeProvider);
|
deps.TimeProvider);
|
||||||
|
|
||||||
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
|
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
|
||||||
|
|
@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests
|
||||||
services.GetRequiredService<PerkUnlockEngine>(),
|
services.GetRequiredService<PerkUnlockEngine>(),
|
||||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests
|
||||||
PerkUnlockEngine PerkUnlockEngine,
|
PerkUnlockEngine PerkUnlockEngine,
|
||||||
IPlayerPerkStateStore PerkStore,
|
IPlayerPerkStateStore PerkStore,
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
|
IReputationDeltaStore ReputationDeltaStore,
|
||||||
TimeProvider TimeProvider);
|
TimeProvider TimeProvider);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
||||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
/// <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; }
|
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>
|
/// <summary>World position for the dev player at process start.</summary>
|
||||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
@ -18,7 +19,8 @@ public static class QuestAcceptApi
|
||||||
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
||||||
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
|
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
|
||||||
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
|
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
|
||||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
|
IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
|
||||||
|
TimeProvider timeProvider) =>
|
||||||
{
|
{
|
||||||
if (body is not null &&
|
if (body is not null &&
|
||||||
body.SchemaVersion != 0 &&
|
body.SchemaVersion != 0 &&
|
||||||
|
|
@ -46,6 +48,7 @@ public static class QuestAcceptApi
|
||||||
perkUnlockEngine,
|
perkUnlockEngine,
|
||||||
perkStore,
|
perkStore,
|
||||||
deliveryStore,
|
deliveryStore,
|
||||||
|
standingStore,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
|
|
||||||
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
|
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Rewards;
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
|
@ -29,6 +30,7 @@ public static class QuestStateOperations
|
||||||
PerkUnlockEngine perkUnlockEngine,
|
PerkUnlockEngine perkUnlockEngine,
|
||||||
IPlayerPerkStateStore perkStore,
|
IPlayerPerkStateStore perkStore,
|
||||||
IRewardDeliveryStore deliveryStore,
|
IRewardDeliveryStore deliveryStore,
|
||||||
|
IFactionStandingStore standingStore,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
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))
|
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
|
||||||
{
|
{
|
||||||
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---
|
// --- 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 NotActive = "not_active";
|
||||||
|
|
||||||
public const string InvalidStepIndex = "invalid_step_index";
|
public const string InvalidStepIndex = "invalid_step_index";
|
||||||
|
|
||||||
|
public const string FactionGateBlocked = "faction_gate_blocked";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,13 @@ if (app.Environment.IsDevelopment() ||
|
||||||
app.MapCombatTargetFixtureApi();
|
app.MapCombatTargetFixtureApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment() ||
|
||||||
|
app.Environment.IsEnvironment("Testing") ||
|
||||||
|
app.Configuration.GetValue<bool>("Game:EnableQuestFixtureApi"))
|
||||||
|
{
|
||||||
|
app.MapQuestFixtureApi();
|
||||||
|
}
|
||||||
|
|
||||||
app.MapTargetingApi();
|
app.MapTargetingApi();
|
||||||
app.MapHotbarLoadoutApi();
|
app.MapHotbarLoadoutApi();
|
||||||
app.MapCooldownSnapshotApi();
|
app.MapCooldownSnapshotApi();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
},
|
},
|
||||||
"Game": {
|
"Game": {
|
||||||
"EnableMasteryFixtureApi": true,
|
"EnableMasteryFixtureApi": true,
|
||||||
"EnableCombatTargetFixtureApi": true
|
"EnableCombatTargetFixtureApi": true,
|
||||||
|
"EnableQuestFixtureApi": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,20 @@ Game code applies standing changes through **`ReputationOperations.TryApplyDelta
|
||||||
|
|
||||||
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md).
|
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md).
|
||||||
|
|
||||||
|
## FactionGateOperations (NEO-137)
|
||||||
|
|
||||||
|
Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperations.TryEvaluate`** before activation. Wired from **`QuestStateOperations.TryAccept`** after prerequisite checks and before **`TryActivate`**.
|
||||||
|
|
||||||
|
**Semantics:** all rules must pass (AND). Empty rule list succeeds immediately. Each rule compares player standing from **`IFactionStandingStore.TryGetStanding`** against **`minStanding`** with **`standing >= minStanding`**. Unknown faction ids fail closed via the standing store boundary (startup catalog cross-ref prevents bad content at load).
|
||||||
|
|
||||||
|
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
|
||||||
|
|
||||||
|
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. Rep grants that reach +15 standing land in NEO-138; success Bruno smoke deferred until then. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** to mark operator chain completed without reward delivery (standing **0**).
|
||||||
|
|
||||||
|
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
|
||||||
|
|
||||||
|
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** and **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed. **400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` pre-request.
|
||||||
|
|
||||||
## Resource-node catalog (`content/resource-nodes`, NEO-58)
|
## Resource-node catalog (`content/resource-nodes`, NEO-58)
|
||||||
|
|
||||||
On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||||
|
|
@ -281,14 +295,14 @@ Sample default response (no quests accepted):
|
||||||
|
|
||||||
### Quest accept POST (NEO-120)
|
### Quest accept POST (NEO-120)
|
||||||
|
|
||||||
**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted).
|
**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted). Faction-gated quests (NEO-137) deny with **`faction_gate_blocked`** when standing is below any **`FactionGateRule.minStanding`** after prerequisites pass.
|
||||||
|
|
||||||
Response (`schemaVersion` **1**): **`accepted`** (`true`/`false`), optional **`reasonCode`** on deny, optional **`quest`** row (same shape as GET list entries). Structured denies return **HTTP 200** with **`accepted: false`** (inventory/craft precedent). Accept-time **`inventory_has_item`** wiring runs inside **`TryAccept`** (NEO-118).
|
Response (`schemaVersion` **1**): **`accepted`** (`true`/`false`), optional **`reasonCode`** on deny, optional **`quest`** row (same shape as GET list entries). Structured denies return **HTTP 200** with **`accepted: false`** (inventory/craft precedent). Accept-time **`inventory_has_item`** wiring runs inside **`TryAccept`** (NEO-118).
|
||||||
|
|
||||||
| Outcome | `accepted` | `reasonCode` | `quest` row |
|
| Outcome | `accepted` | `reasonCode` | `quest` row |
|
||||||
|---------|------------|--------------|-------------|
|
|---------|------------|--------------|-------------|
|
||||||
| Success | `true` | omitted | active snapshot |
|
| Success | `true` | omitted | active snapshot |
|
||||||
| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete` | omitted |
|
| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete`, `faction_gate_blocked` | omitted |
|
||||||
| Deny (existing row) | `false` | e.g. `already_active`, `already_completed` | current snapshot |
|
| Deny (existing row) | `false` | e.g. `already_active`, `already_completed` | current snapshot |
|
||||||
|
|
||||||
Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).
|
Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue