NEO-140: project faction gates and rep grants on quest HTTP GETs
World quest-definitions expose optional factionGateRules; quest-progress completionRewardSummary maps GrantedReputation to reputationGrants.pull/180/head
parent
367f894b34
commit
2b4a0d1e34
|
|
@ -68,5 +68,16 @@ tests {
|
|||
itemId: "survey_drone_kit",
|
||||
quantity: 1,
|
||||
});
|
||||
expect(row.factionGateRules).to.eql([
|
||||
{ factionId: "prototype_faction_grid_operators", minStanding: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("non-gated quests omit factionGateRules", function () {
|
||||
const body = res.getBody();
|
||||
const gatherIntro = body.quests.find((x) => x.id === "prototype_quest_gather_intro");
|
||||
expect(gatherIntro.factionGateRules).to.equal(undefined);
|
||||
const operatorChain = body.quests.find((x) => x.id === "prototype_quest_operator_chain");
|
||||
expect(operatorChain.factionGateRules).to.equal(undefined);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ tests {
|
|||
expect(row.completionRewardSummary.skillXpGrants).to.eql([
|
||||
{ skillId: "salvage", amount: 25 },
|
||||
]);
|
||||
expect(row.completionRewardSummary.reputationGrants).to.eql([]);
|
||||
});
|
||||
|
||||
test("second GET completionRewardSummary matches first GET", function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
meta {
|
||||
name: GET quest progress after operator chain complete
|
||||
type: http
|
||||
seq: 12
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-140: complete operator chain via HTTP quest flow, GET quest-progress asserts completionRewardSummary.reputationGrants (+15 Grid Operators).
|
||||
Pre-request runs operator-chain-quest-flow-helper (gather/combat/refine intros + chain steps).
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const { completeOperatorChainQuestFlow, CHAIN_QUEST_ID } = require("./scripts/operator-chain-quest-flow-helper");
|
||||
await completeOperatorChainQuestFlow(bru);
|
||||
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/game/players/${playerId}/quest-progress`,
|
||||
{ validateStatus: () => true },
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`quest-progress GET failed: ${response.status}`);
|
||||
}
|
||||
const row = response.data.quests.find((x) => x.questId === CHAIN_QUEST_ID);
|
||||
if (!row || row.status !== "completed" || !row.completionRewardSummary) {
|
||||
throw new Error(`expected operator chain completed with summary, got ${JSON.stringify(row)}`);
|
||||
}
|
||||
bru.setVar(
|
||||
"firstOperatorChainCompletionRewardSummary",
|
||||
JSON.stringify(row.completionRewardSummary),
|
||||
);
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("operator chain row is completed with reputationGrants in completionRewardSummary", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
const row = body.quests.find((x) => x.questId === "prototype_quest_operator_chain");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.status).to.equal("completed");
|
||||
expect(row.completionRewardSummary).to.be.an("object");
|
||||
expect(row.completionRewardSummary.itemGrants).to.eql([
|
||||
{ itemId: "survey_drone_kit", quantity: 1 },
|
||||
]);
|
||||
expect(row.completionRewardSummary.skillXpGrants).to.eql([
|
||||
{ skillId: "salvage", amount: 50 },
|
||||
]);
|
||||
expect(row.completionRewardSummary.reputationGrants).to.eql([
|
||||
{ factionId: "prototype_faction_grid_operators", amount: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("second GET completionRewardSummary matches first GET", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.quests.find((x) => x.questId === "prototype_quest_operator_chain");
|
||||
const firstSummary = JSON.parse(bru.getVar("firstOperatorChainCompletionRewardSummary"));
|
||||
expect(row.completionRewardSummary).to.eql(firstSummary);
|
||||
});
|
||||
}
|
||||
|
|
@ -74,5 +74,13 @@ public class QuestDefinitionsWorldApiTests
|
|||
Assert.Equal("inventory_has_item", terminalObjective.Kind);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId);
|
||||
Assert.Equal(1, terminalObjective.Quantity);
|
||||
Assert.Null(operatorChain.FactionGateRules);
|
||||
|
||||
var gridContract = body.Quests.Single(q => q.Id == PrototypeE7M1QuestCatalogRules.GridContractQuestId);
|
||||
Assert.NotNull(gridContract.FactionGateRules);
|
||||
Assert.Single(gridContract.FactionGateRules!);
|
||||
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractGateFactionId, gridContract.FactionGateRules![0].FactionId);
|
||||
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractMinStanding, gridContract.FactionGateRules![0].MinStanding);
|
||||
Assert.Null(gatherIntro.FactionGateRules);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Single(summary.SkillXpGrants);
|
||||
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
|
||||
Assert.Equal(25, summary.SkillXpGrants[0].Amount);
|
||||
Assert.Empty(summary.ReputationGrants);
|
||||
}
|
||||
|
||||
private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
|
||||
|
|
@ -302,6 +303,9 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Single(summary.SkillXpGrants);
|
||||
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
|
||||
Assert.Equal(50, summary.SkillXpGrants[0].Amount);
|
||||
Assert.Single(summary.ReputationGrants);
|
||||
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractGateFactionId, summary.ReputationGrants[0].FactionId);
|
||||
Assert.Equal(15, summary.ReputationGrants[0].Amount);
|
||||
}
|
||||
|
||||
private static void AssertCompletionRewardSummariesEqual(
|
||||
|
|
@ -323,6 +327,13 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId);
|
||||
Assert.Equal(first.SkillXpGrants[i].Amount, second.SkillXpGrants[i].Amount);
|
||||
}
|
||||
|
||||
Assert.Equal(first.ReputationGrants.Count, second.ReputationGrants.Count);
|
||||
for (var i = 0; i < first.ReputationGrants.Count; i++)
|
||||
{
|
||||
Assert.Equal(first.ReputationGrants[i].FactionId, second.ReputationGrants[i].FactionId);
|
||||
Assert.Equal(first.ReputationGrants[i].Amount, second.ReputationGrants[i].Amount);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteOperatorChain(QuestWiringTestDependencies deps)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,21 @@ public sealed class QuestDefinitionJson
|
|||
|
||||
[JsonPropertyName("steps")]
|
||||
public required IReadOnlyList<QuestStepDefinitionJson> Steps { get; init; }
|
||||
|
||||
/// <summary>Minimum-standing gates for quest accept; omitted when the quest has no rules (NEO-140).</summary>
|
||||
[JsonPropertyName("factionGateRules")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<QuestFactionGateRuleJson>? FactionGateRules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One faction gate rule in the read-only quest definition projection (NEO-140).</summary>
|
||||
public sealed class QuestFactionGateRuleJson
|
||||
{
|
||||
[JsonPropertyName("factionId")]
|
||||
public required string FactionId { get; init; }
|
||||
|
||||
[JsonPropertyName("minStanding")]
|
||||
public int MinStanding { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One step in the read-only quest definition projection.</summary>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public static class QuestDefinitionsWorldApi
|
|||
DisplayName = d.DisplayName,
|
||||
PrerequisiteQuestIds = d.PrerequisiteQuestIds,
|
||||
Steps = MapSteps(d.Steps),
|
||||
FactionGateRules = MapFactionGateRules(d.FactionGateRules),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,27 @@ public static class QuestDefinitionsWorldApi
|
|||
return app;
|
||||
}
|
||||
|
||||
private static List<QuestFactionGateRuleJson>? MapFactionGateRules(IReadOnlyList<FactionGateRuleRow> rules)
|
||||
{
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = new List<QuestFactionGateRuleJson>(rules.Count);
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
list.Add(
|
||||
new QuestFactionGateRuleJson
|
||||
{
|
||||
FactionId = rule.FactionId,
|
||||
MinStanding = rule.MinStanding,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<QuestStepDefinitionJson> MapSteps(IReadOnlyList<QuestStepDefRow> steps)
|
||||
{
|
||||
var list = new List<QuestStepDefinitionJson>(steps.Count);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ public static class QuestProgressApi
|
|||
{
|
||||
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
|
||||
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
|
||||
ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +170,24 @@ public static class QuestProgressApi
|
|||
return summary;
|
||||
}
|
||||
|
||||
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedReputation"/>.</summary>
|
||||
private static List<QuestReputationGrantJson> MapReputationGrants(
|
||||
IReadOnlyList<RewardReputationGrantApplied> grants)
|
||||
{
|
||||
var summary = new List<QuestReputationGrantJson>(grants.Count);
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
summary.Add(
|
||||
new QuestReputationGrantJson
|
||||
{
|
||||
FactionId = grant.FactionId,
|
||||
Amount = grant.Amount,
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static QuestProgressRowJson NotStartedRow(string questId) =>
|
||||
new()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ public sealed class QuestProgressRowJson
|
|||
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Item + skill XP grants applied on first-time quest completion (NEO-129).</summary>
|
||||
/// <summary>Item, skill XP, and reputation grants applied on first-time quest completion (NEO-129, NEO-140).</summary>
|
||||
public sealed class QuestCompletionRewardSummaryJson
|
||||
{
|
||||
[JsonPropertyName("itemGrants")]
|
||||
|
|
@ -53,6 +53,9 @@ public sealed class QuestCompletionRewardSummaryJson
|
|||
|
||||
[JsonPropertyName("skillXpGrants")]
|
||||
public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; }
|
||||
|
||||
[JsonPropertyName("reputationGrants")]
|
||||
public required IReadOnlyList<QuestReputationGrantJson> ReputationGrants { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
|
||||
|
|
@ -74,3 +77,13 @@ public sealed class QuestSkillXpGrantJson
|
|||
[JsonPropertyName("amount")]
|
||||
public required int Amount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One reputation grant line in <see cref="QuestCompletionRewardSummaryJson"/> (NEO-140).</summary>
|
||||
public sealed class QuestReputationGrantJson
|
||||
{
|
||||
[JsonPropertyName("factionId")]
|
||||
public required string FactionId { get; init; }
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public required int Amount { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ On success, **Information** logs include the resolved quests directory path, dis
|
|||
|
||||
## Quest definitions (NEO-115)
|
||||
|
||||
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
|
||||
```bash
|
||||
curl -sS -i "http://localhost:5253/game/world/quest-definitions"
|
||||
|
|
@ -310,9 +310,9 @@ Completed rows cannot regress to active without a reset API (none in prototype).
|
|||
| Field | When present |
|
||||
|-------|----------------|
|
||||
| **`completedAt`** | **`status: completed`** |
|
||||
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`) and **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`). Omitted when not completed or when no delivery record exists. |
|
||||
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`), **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`), and **`GrantedReputation`** → **`reputationGrants`** (`factionId`, `amount`). Omitted when not completed or when no delivery record exists. |
|
||||
|
||||
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`** and empty **`itemGrants`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
|
||||
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`**, empty **`itemGrants`**, and empty **`reputationGrants`**. Operator chain completion adds **`reputationGrants: [{ factionId: prototype_faction_grid_operators, amount: 15 }]`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); rep projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`, `Get quest progress after operator chain complete.bru`.
|
||||
|
||||
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue