NEO-137: Address review — quest fixture for Bruno CI and test/docs nits.
Add POST …/__dev/quest-fixture to mark quests completed without reward delivery; wire Bruno pre-request and CI flag. Multi-rule AND unit test, README notes, gate deny comment, review strikethroughs.pull/176/head
parent
4ffc60f226
commit
a36e7b56b5
|
|
@ -93,6 +93,7 @@ jobs:
|
|||
ConnectionStrings__NeonSprawl: >-
|
||||
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
||||
Game__EnableCombatTargetFixtureApi: "true"
|
||||
Game__EnableQuestFixtureApi: "true"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
|
||||
|
|
|
|||
|
|
@ -6,11 +6,32 @@ meta {
|
|||
|
||||
docs {
|
||||
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15.
|
||||
Prerequisite setup: mark prototype_quest_operator_chain completed (store seed or full chain playthrough).
|
||||
Fresh dev player with chain completed but standing 0 denies faction_gate_blocked before activation.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ Other decisions settled by Linear AC, backlog, and repo precedent (no additional
|
|||
- **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`; success deferred to NEO-138.
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** Branch `NEO-137-e7m3-faction-gate-operations-tryaccept` — commits `1c3d075` … `0241cc3` vs `main`
|
||||
**Base:** `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**
|
||||
~~**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`**).
|
||||
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
|
||||
|
||||
|
|
@ -26,17 +27,17 @@ This branch lands **E7M3-05**: **`FactionGateOperations.TryEvaluate`** evaluates
|
|||
|
||||
## 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
- 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.
|
||||
- ~~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
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,28 @@ public sealed class FactionGateOperationsTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -76,7 +98,7 @@ public sealed class FactionGateOperationsTests
|
|||
[Fact]
|
||||
public async Task Host_ShouldResolveStandingStoreForGateEval_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ public sealed class GamePositionOptions
|
|||
/// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary>
|
||||
public bool EnableCombatTargetFixtureApi { get; set; }
|
||||
|
||||
/// <summary>When true, maps <c>POST …/__dev/quest-fixture</c> for Bruno/manual QA quest progress setup (also enabled in Development).</summary>
|
||||
public bool EnableQuestFixtureApi { get; set; }
|
||||
|
||||
/// <summary>World position for the dev player at process start.</summary>
|
||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ public static class QuestStateOperations
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ if (app.Environment.IsDevelopment() ||
|
|||
app.MapCombatTargetFixtureApi();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
app.Configuration.GetValue<bool>("Game:EnableQuestFixtureApi"))
|
||||
{
|
||||
app.MapQuestFixtureApi();
|
||||
}
|
||||
|
||||
app.MapTargetingApi();
|
||||
app.MapHotbarLoadoutApi();
|
||||
app.MapCooldownSnapshotApi();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
},
|
||||
"Game": {
|
||||
"EnableMasteryFixtureApi": true,
|
||||
"EnableCombatTargetFixtureApi": true
|
||||
"EnableCombatTargetFixtureApi": true,
|
||||
"EnableQuestFixtureApi": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,10 +104,12 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
|
|||
|
||||
**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.
|
||||
**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)
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue